@cfast/ui 0.2.0 → 0.3.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Schmidt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -788,6 +788,130 @@ type JsonFieldProps = BaseFieldProps & {
788
788
  /** Whether to initially show a collapsed preview. */
789
789
  collapsed?: boolean;
790
790
  };
791
+ /**
792
+ * Per-file upload state tracked internally by {@link UploadFieldProps}.
793
+ *
794
+ * Each entry corresponds to a file the user selected (or dropped) into the
795
+ * field. Once a file finishes uploading successfully its `key` is appended
796
+ * to the controlled `value` array and the entry can be removed from this
797
+ * tracker. Failed uploads stay in the tracker so the user can see (and
798
+ * dismiss) the error inline without losing previously uploaded files.
799
+ */
800
+ type UploadFieldFile = {
801
+ /** Stable id for React keying / tracking across renders. */
802
+ id: string;
803
+ /** Display name (taken from `File.name`). */
804
+ name: string;
805
+ /** File size in bytes. */
806
+ size: number;
807
+ /** MIME type as reported by the browser. */
808
+ type: string;
809
+ /** Current upload progress as a 0-100 percentage. */
810
+ progress: number;
811
+ /** Lifecycle status. */
812
+ status: "pending" | "uploading" | "success" | "error";
813
+ /** Server-side R2 key, only set after a successful upload. */
814
+ key?: string;
815
+ /** Inline error message if `status === "error"`. */
816
+ error?: string;
817
+ };
818
+ /**
819
+ * Props for the headless {@link UploadField} component.
820
+ *
821
+ * `UploadField` is a controlled, multi-file upload widget built on the
822
+ * opinionated `POST /uploads/:filetype` route exposed by `@cfast/storage`.
823
+ * Unlike {@link DropZoneProps} (which only wraps a single `useUpload`
824
+ * result), `UploadField` is the form-friendly variant: it owns its own
825
+ * upload state, integrates with `react-hook-form` via the controlled
826
+ * `value`/`onChange` pair, enforces `maxFiles`, and surfaces per-file
827
+ * progress + per-file error reporting.
828
+ *
829
+ * The field reports its current value as `string[]` of R2 keys — exactly
830
+ * the shape you would store on a Drizzle column like `text("image_key")`.
831
+ *
832
+ * @see {@link DropZoneProps} for the lower-level single-upload primitive.
833
+ * @see {@link UploadFieldFile} for the per-file tracker entries.
834
+ */
835
+ type UploadFieldProps = BaseFieldProps & {
836
+ /**
837
+ * The filetype name registered in `defineStorage`. Used as the last
838
+ * segment of the upload POST URL — `${basePath}/${filetype}`.
839
+ */
840
+ filetype: string;
841
+ /**
842
+ * Mount path of the storage routes from `@cfast/storage/plugin`.
843
+ * Defaults to `"/uploads"`. Must match the `basePath` used in
844
+ * `storageRoutes()` / `createStorageRouteHandlers`.
845
+ */
846
+ basePath?: string;
847
+ /**
848
+ * Whether the field accepts multiple files. When `false` (default), the
849
+ * controlled value still flows through as `string[]` but only ever
850
+ * contains zero or one entries.
851
+ */
852
+ multiple?: boolean;
853
+ /**
854
+ * Hard cap on the number of files the user is allowed to upload through
855
+ * this field. Files dropped or selected past the cap are rejected with
856
+ * an inline error and never start uploading. Defaults to `1` when
857
+ * `multiple` is `false`, otherwise unlimited.
858
+ */
859
+ maxFiles?: number;
860
+ /**
861
+ * The HTML `accept` string passed to the underlying `<input type="file">`
862
+ * (e.g. `"image/*"` or `"image/png,image/webp"`). This should match the
863
+ * MIME types declared on the corresponding storage filetype so the
864
+ * browser-side picker can pre-filter selections.
865
+ */
866
+ accept?: string;
867
+ /**
868
+ * Optional client-side max size in bytes. Files larger than this are
869
+ * rejected before any bytes are sent. Server-side validation is still
870
+ * the source of truth — this is only a hint to the user.
871
+ */
872
+ maxSize?: number;
873
+ /**
874
+ * Controlled array of R2 keys. When `multiple` is `false`, treat this
875
+ * as a single-element array (the first entry is the active value).
876
+ */
877
+ value?: string[];
878
+ /**
879
+ * Called with the new array of keys whenever the set of successfully
880
+ * uploaded files changes — after a successful upload, after a removal,
881
+ * etc. Wire this to your `react-hook-form` `setValue` callback or to
882
+ * a controlled component's local state.
883
+ */
884
+ onChange?: (keys: string[]) => void;
885
+ /**
886
+ * Called whenever a per-file upload fails (network error, validation
887
+ * error, server-side rejection). Receives the file tracker entry — the
888
+ * `error` field on the entry is the rendered message.
889
+ */
890
+ onError?: (file: UploadFieldFile) => void;
891
+ /**
892
+ * Whether the field is disabled. Disabled fields render their existing
893
+ * value but don't accept new files and don't render the drop zone in
894
+ * an interactive state.
895
+ */
896
+ disabled?: boolean;
897
+ /**
898
+ * Custom uploader implementation. Defaults to a built-in `XMLHttpRequest`
899
+ * uploader that hits `${basePath}/${filetype}`. Override this in tests
900
+ * (or in apps that wrap the upload in extra logic) — both `@cfast/ui`'s
901
+ * own tests and the Joy variant's tests pass a mock through this prop
902
+ * instead of stubbing `XMLHttpRequest` globally.
903
+ */
904
+ uploader?: (file: File, options: {
905
+ url: string;
906
+ onProgress: (percent: number) => void;
907
+ signal?: AbortSignal;
908
+ }) => Promise<{
909
+ key: string;
910
+ size: number;
911
+ type: string;
912
+ url?: string;
913
+ }>;
914
+ };
791
915
  /**
792
916
  * Toast notification severity levels.
793
917
  *
@@ -1881,4 +2005,4 @@ declare function ListView<T = unknown>({ title, data, table: _table, columns, ac
1881
2005
  */
1882
2006
  declare function DetailView<T = unknown>({ title, table, record, fields: fieldsProp, exclude, breadcrumb, }: DetailViewProps<T>): react_jsx_runtime.JSX.Element;
1883
2007
 
1884
- export { ImagePreview as $, type AppShellProps as A, type BooleanFieldProps as B, type ChipSlotProps as C, type DateFieldProps as D, type EmailFieldProps as E, type FileFieldProps as F, type DetailViewProps as G, DropZone as H, type ImageFieldProps as I, type JsonFieldProps as J, type DropZoneProps as K, type DropZoneSlotProps as L, FileList$1 as M, type NumberFieldProps as N, type FileListFile as O, type FileListProps as P, FilterBar as Q, type RelationFieldProps as R, type FilterBarProps as S, type TextFieldProps as T, type UrlFieldProps as U, type FilterDef as V, type FilterOption as W, type FilterType as X, FormStatus as Y, type FormStatusData as Z, type FormStatusProps as _, type FieldComponent as a, type ImagePreviewProps as a0, ImpersonationBanner as a1, type ImpersonationBannerProps as a2, ListView as a3, type ListViewProps as a4, type PageContainerSlotProps as a5, PermissionGate as a6, type PermissionGateProps as a7, RoleBadge as a8, type RoleBadgeProps as a9, type SidebarSlotProps as aa, type TableCellSlotProps as ab, type TableRowSlotProps as ac, type TableSectionSlotProps as ad, type TableSlotProps as ae, type ToastApi as af, ToastContext as ag, type ToastOptions as ah, type ToastSlotProps as ai, type ToastType as aj, type TooltipSlotProps as ak, type UIPlugin as al, type UIPluginComponents as am, UIPluginProvider as an, type UserMenuLink as ao, type WhenForbidden as ap, createUIPlugin as aq, getInitials as ar, useActionToast as as, useColumnInference as at, useComponent as au, useConfirm as av, useToast as aw, useUIPlugin as ax, type EmptyStateProps as b, type NavigationProgressProps as c, type BreadcrumbItem as d, type TabItem as e, type NavigationItem as f, type UserMenuProps as g, ActionButton as h, type ActionButtonProps as i, type AlertSlotProps as j, type AppShellSlotProps as k, AvatarWithInitials as l, type AvatarWithInitialsProps as m, type BaseFieldProps as n, type BreadcrumbSlotProps as o, type BulkAction as p, BulkActionBar as q, type ButtonSlotProps as r, type ColumnDef as s, type ColumnShorthand as t, type ConfirmDialogSlotProps as u, type ConfirmOptions as v, ConfirmProvider as w, DataTable as x, type DataTableProps as y, DetailView as z };
2008
+ export { type FormStatusProps as $, type AppShellProps as A, type BooleanFieldProps as B, type ChipSlotProps as C, type DateFieldProps as D, type EmailFieldProps as E, type FileFieldProps as F, DetailView as G, type DetailViewProps as H, type ImageFieldProps as I, type JsonFieldProps as J, DropZone as K, type DropZoneProps as L, type DropZoneSlotProps as M, type NumberFieldProps as N, FileList$1 as O, type FileListFile as P, type FileListProps as Q, type RelationFieldProps as R, FilterBar as S, type TextFieldProps as T, type UrlFieldProps as U, type FilterBarProps as V, type FilterDef as W, type FilterOption as X, type FilterType as Y, FormStatus as Z, type FormStatusData as _, type UploadFieldProps as a, ImagePreview as a0, type ImagePreviewProps as a1, ImpersonationBanner as a2, type ImpersonationBannerProps as a3, ListView as a4, type ListViewProps as a5, type PageContainerSlotProps as a6, PermissionGate as a7, type PermissionGateProps as a8, RoleBadge as a9, type RoleBadgeProps as aa, type SidebarSlotProps as ab, type TableCellSlotProps as ac, type TableRowSlotProps as ad, type TableSectionSlotProps as ae, type TableSlotProps as af, type ToastApi as ag, ToastContext as ah, type ToastOptions as ai, type ToastSlotProps as aj, type ToastType as ak, type TooltipSlotProps as al, type UIPlugin as am, type UIPluginComponents as an, UIPluginProvider as ao, type UploadFieldFile as ap, type UserMenuLink as aq, type WhenForbidden as ar, createUIPlugin as as, getInitials as at, useActionToast as au, useColumnInference as av, useComponent as aw, useConfirm as ax, useToast as ay, useUIPlugin as az, type FieldComponent as b, type EmptyStateProps as c, type NavigationProgressProps as d, type BreadcrumbItem as e, type TabItem as f, type NavigationItem as g, type UserMenuProps as h, ActionButton as i, type ActionButtonProps as j, type AlertSlotProps as k, type AppShellSlotProps as l, AvatarWithInitials as m, type AvatarWithInitialsProps as n, type BaseFieldProps as o, type BreadcrumbSlotProps as p, type BulkAction as q, BulkActionBar as r, type ButtonSlotProps as s, type ColumnDef as t, type ColumnShorthand as u, type ConfirmDialogSlotProps as v, type ConfirmOptions as w, ConfirmProvider as x, DataTable as y, type DataTableProps as z };
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { h as ActionButton, l as AvatarWithInitials, q as BulkActionBar, w as ConfirmProvider, x as DataTable, z as DetailView, H as DropZone, M as FileList, Q as FilterBar, Y as FormStatus, $ as ImagePreview, a1 as ImpersonationBanner, a3 as ListView, a6 as PermissionGate, a8 as RoleBadge, an as UIPluginProvider, aq as createUIPlugin, ar as getInitials, as as useActionToast, at as useColumnInference, au as useComponent, av as useConfirm, aw as useToast, ax as useUIPlugin } from './client-CIx8_tmv.js';
1
+ export { i as ActionButton, m as AvatarWithInitials, r as BulkActionBar, x as ConfirmProvider, y as DataTable, G as DetailView, K as DropZone, O as FileList, S as FilterBar, Z as FormStatus, a0 as ImagePreview, a2 as ImpersonationBanner, a4 as ListView, a7 as PermissionGate, a9 as RoleBadge, ao as UIPluginProvider, as as createUIPlugin, at as getInitials, au as useActionToast, av as useColumnInference, aw as useComponent, ax as useConfirm, ay as useToast, az as useUIPlugin } from './client-C0K-7jLA.js';
2
2
  import 'react/jsx-runtime';
3
3
  import 'react';
4
4
  import '@cfast/actions/client';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { D as DateFieldProps, B as BooleanFieldProps, N as NumberFieldProps, T as TextFieldProps, E as EmailFieldProps, U as UrlFieldProps, I as ImageFieldProps, F as FileFieldProps, R as RelationFieldProps, J as JsonFieldProps, a as FieldComponent, b as EmptyStateProps, c as NavigationProgressProps, d as BreadcrumbItem, e as TabItem, A as AppShellProps, f as NavigationItem, g as UserMenuProps } from './client-CIx8_tmv.js';
2
- export { h as ActionButton, i as ActionButtonProps, j as AlertSlotProps, k as AppShellSlotProps, l as AvatarWithInitials, m as AvatarWithInitialsProps, n as BaseFieldProps, o as BreadcrumbSlotProps, p as BulkAction, q as BulkActionBar, r as ButtonSlotProps, C as ChipSlotProps, s as ColumnDef, t as ColumnShorthand, u as ConfirmDialogSlotProps, v as ConfirmOptions, w as ConfirmProvider, x as DataTable, y as DataTableProps, z as DetailView, G as DetailViewProps, H as DropZone, K as DropZoneProps, L as DropZoneSlotProps, M as FileList, O as FileListFile, P as FileListProps, Q as FilterBar, S as FilterBarProps, V as FilterDef, W as FilterOption, X as FilterType, Y as FormStatus, Z as FormStatusData, _ as FormStatusProps, $ as ImagePreview, a0 as ImagePreviewProps, a1 as ImpersonationBanner, a2 as ImpersonationBannerProps, a3 as ListView, a4 as ListViewProps, a5 as PageContainerSlotProps, a6 as PermissionGate, a7 as PermissionGateProps, a8 as RoleBadge, a9 as RoleBadgeProps, aa as SidebarSlotProps, ab as TableCellSlotProps, ac as TableRowSlotProps, ad as TableSectionSlotProps, ae as TableSlotProps, af as ToastApi, ag as ToastContext, ah as ToastOptions, ai as ToastSlotProps, aj as ToastType, ak as TooltipSlotProps, al as UIPlugin, am as UIPluginComponents, an as UIPluginProvider, ao as UserMenuLink, ap as WhenForbidden, aq as createUIPlugin, ar as getInitials, as as useActionToast, at as useColumnInference, au as useComponent, av as useConfirm, aw as useToast, ax as useUIPlugin } from './client-CIx8_tmv.js';
1
+ import { D as DateFieldProps, B as BooleanFieldProps, N as NumberFieldProps, T as TextFieldProps, E as EmailFieldProps, U as UrlFieldProps, I as ImageFieldProps, F as FileFieldProps, R as RelationFieldProps, J as JsonFieldProps, a as UploadFieldProps, b as FieldComponent, c as EmptyStateProps, d as NavigationProgressProps, e as BreadcrumbItem, f as TabItem, A as AppShellProps, g as NavigationItem, h as UserMenuProps } from './client-C0K-7jLA.js';
2
+ export { i as ActionButton, j as ActionButtonProps, k as AlertSlotProps, l as AppShellSlotProps, m as AvatarWithInitials, n as AvatarWithInitialsProps, o as BaseFieldProps, p as BreadcrumbSlotProps, q as BulkAction, r as BulkActionBar, s as ButtonSlotProps, C as ChipSlotProps, t as ColumnDef, u as ColumnShorthand, v as ConfirmDialogSlotProps, w as ConfirmOptions, x as ConfirmProvider, y as DataTable, z as DataTableProps, G as DetailView, H as DetailViewProps, K as DropZone, L as DropZoneProps, M as DropZoneSlotProps, O as FileList, P as FileListFile, Q as FileListProps, S as FilterBar, V as FilterBarProps, W as FilterDef, X as FilterOption, Y as FilterType, Z as FormStatus, _ as FormStatusData, $ as FormStatusProps, a0 as ImagePreview, a1 as ImagePreviewProps, a2 as ImpersonationBanner, a3 as ImpersonationBannerProps, a4 as ListView, a5 as ListViewProps, a6 as PageContainerSlotProps, a7 as PermissionGate, a8 as PermissionGateProps, a9 as RoleBadge, aa as RoleBadgeProps, ab as SidebarSlotProps, ac as TableCellSlotProps, ad as TableRowSlotProps, ae as TableSectionSlotProps, af as TableSlotProps, ag as ToastApi, ah as ToastContext, ai as ToastOptions, aj as ToastSlotProps, ak as ToastType, al as TooltipSlotProps, am as UIPlugin, an as UIPluginComponents, ao as UIPluginProvider, ap as UploadFieldFile, aq as UserMenuLink, ar as WhenForbidden, as as createUIPlugin, at as getInitials, au as useActionToast, av as useColumnInference, aw as useComponent, ax as useConfirm, ay as useToast, az as useUIPlugin } from './client-C0K-7jLA.js';
3
3
  import * as react_jsx_runtime from 'react/jsx-runtime';
4
4
  import { ReactNode } from 'react';
5
5
  import { ClientDescriptor, ActionHookResult } from '@cfast/actions/client';
@@ -188,6 +188,39 @@ declare function RelationField({ value, display, linkTo, }: RelationFieldProps):
188
188
  */
189
189
  declare function JsonField({ value, collapsed, }: JsonFieldProps): react_jsx_runtime.JSX.Element;
190
190
 
191
+ /**
192
+ * Headless multi-file upload field for cfast forms.
193
+ *
194
+ * `UploadField` is a controlled, form-friendly component that uploads files
195
+ * through `@cfast/storage`'s opinionated `POST /uploads/:filetype` route
196
+ * and surfaces the resulting R2 keys via the `value` / `onChange` pair.
197
+ *
198
+ * It is intentionally headless: the markup is plain HTML so consumers can
199
+ * style it directly, and the Joy UI variant lives in `@cfast/joy`. The
200
+ * component handles:
201
+ *
202
+ * - Drag-and-drop and click-to-browse selection.
203
+ * - Per-file upload progress (via the configurable {@link UploadFieldProps.uploader}).
204
+ * - Per-file inline error reporting (server, network, or validation).
205
+ * - `accept` / `maxSize` / `maxFiles` enforcement before bytes leave the browser.
206
+ * - Controlled `value` round-tripping for `react-hook-form` and other state libs.
207
+ *
208
+ * @example
209
+ * ```tsx
210
+ * <UploadField
211
+ * filetype="productImages"
212
+ * basePath="/uploads"
213
+ * multiple
214
+ * maxFiles={10}
215
+ * accept="image/*"
216
+ * value={imageKeys}
217
+ * onChange={setImageKeys}
218
+ * onError={(file) => console.warn(file.error)}
219
+ * />
220
+ * ```
221
+ */
222
+ declare function UploadField({ label, className, filetype, basePath, multiple, maxFiles, accept, maxSize, value, onChange, onError, disabled, uploader, }: UploadFieldProps): react_jsx_runtime.JSX.Element;
223
+
191
224
  type ColumnMeta = {
192
225
  dataType: string;
193
226
  name: string;
@@ -459,4 +492,4 @@ declare function getRecordId(obj: unknown): string | number;
459
492
  */
460
493
  declare function useActionStatus(descriptor: ClientDescriptor): ActionHookResult;
461
494
 
462
- export { AppShell, AppShellHeader, AppShellProps, AppShellSidebar, BooleanField, BooleanFieldProps, BreadcrumbItem, DateField, DateFieldProps, EmailField, EmailFieldProps, EmptyState, EmptyStateProps, FileField, FileFieldProps, ImageField, ImageFieldProps, JsonField, JsonFieldProps, NavigationItem, NavigationProgress, NavigationProgressProps, NumberField, NumberFieldProps, PageContainer, RelationField, RelationFieldProps, TabItem, TextField, TextFieldProps, UrlField, UrlFieldProps, UserMenu, UserMenuProps, fieldForColumn, fieldsForTable, getField, getRecordId, useActionStatus };
495
+ export { AppShell, AppShellHeader, AppShellProps, AppShellSidebar, BooleanField, BooleanFieldProps, BreadcrumbItem, DateField, DateFieldProps, EmailField, EmailFieldProps, EmptyState, EmptyStateProps, FileField, FileFieldProps, ImageField, ImageFieldProps, JsonField, JsonFieldProps, NavigationItem, NavigationProgress, NavigationProgressProps, NumberField, NumberFieldProps, PageContainer, RelationField, RelationFieldProps, TabItem, TextField, TextFieldProps, UploadField, UploadFieldProps, UrlField, UrlFieldProps, UserMenu, UserMenuProps, fieldForColumn, fieldsForTable, getField, getRecordId, useActionStatus };
package/dist/index.js CHANGED
@@ -125,9 +125,428 @@ function RelationField({
125
125
  return /* @__PURE__ */ jsx4("span", { children: displayValue });
126
126
  }
127
127
 
128
+ // src/fields/upload-field.tsx
129
+ import { useCallback, useId, useRef, useState } from "react";
130
+ import { jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
131
+ function defaultUploader(file, options) {
132
+ return new Promise((resolve, reject) => {
133
+ const xhr = new XMLHttpRequest();
134
+ xhr.open("POST", options.url);
135
+ xhr.upload.addEventListener("progress", (event) => {
136
+ if (event.lengthComputable) {
137
+ options.onProgress(Math.round(event.loaded / event.total * 100));
138
+ }
139
+ });
140
+ xhr.addEventListener("load", () => {
141
+ if (xhr.status >= 200 && xhr.status < 300) {
142
+ try {
143
+ const parsed = JSON.parse(xhr.responseText);
144
+ if (typeof parsed.key === "string" && typeof parsed.size === "number" && typeof parsed.type === "string") {
145
+ resolve({
146
+ key: parsed.key,
147
+ size: parsed.size,
148
+ type: parsed.type,
149
+ url: typeof parsed.url === "string" ? parsed.url : void 0
150
+ });
151
+ return;
152
+ }
153
+ reject(new Error("Invalid response from server"));
154
+ } catch {
155
+ reject(new Error("Invalid response from server"));
156
+ }
157
+ } else {
158
+ try {
159
+ const parsed = JSON.parse(xhr.responseText);
160
+ const detail = typeof parsed.detail === "string" ? parsed.detail : typeof parsed.message === "string" ? parsed.message : `Upload failed (${xhr.status})`;
161
+ reject(new Error(detail));
162
+ } catch {
163
+ reject(new Error(`Upload failed (${xhr.status})`));
164
+ }
165
+ }
166
+ });
167
+ xhr.addEventListener("error", () => {
168
+ reject(new Error("Network error during upload"));
169
+ });
170
+ if (options.signal) {
171
+ options.signal.addEventListener("abort", () => {
172
+ xhr.abort();
173
+ reject(new Error("Upload cancelled"));
174
+ });
175
+ }
176
+ const formData = new FormData();
177
+ formData.append("file", file);
178
+ xhr.send(formData);
179
+ });
180
+ }
181
+ function matchesAccept(file, accept) {
182
+ if (!accept) return true;
183
+ const tokens = accept.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
184
+ if (tokens.length === 0) return true;
185
+ const fileType = (file.type || "").toLowerCase();
186
+ const fileName = file.name.toLowerCase();
187
+ for (const token of tokens) {
188
+ if (token.startsWith(".")) {
189
+ if (fileName.endsWith(token)) return true;
190
+ } else if (token.endsWith("/*")) {
191
+ const prefix = token.slice(0, -1);
192
+ if (fileType.startsWith(prefix)) return true;
193
+ } else if (token === fileType) {
194
+ return true;
195
+ }
196
+ }
197
+ return false;
198
+ }
199
+ function formatBytes2(bytes) {
200
+ if (bytes < 1024) return `${bytes} B`;
201
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
202
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
203
+ }
204
+ function UploadField({
205
+ label,
206
+ className,
207
+ filetype,
208
+ basePath = "/uploads",
209
+ multiple = false,
210
+ maxFiles,
211
+ accept,
212
+ maxSize,
213
+ value,
214
+ onChange,
215
+ onError,
216
+ disabled = false,
217
+ uploader = defaultUploader
218
+ }) {
219
+ const [files, setFiles] = useState([]);
220
+ const [isDragOver, setIsDragOver] = useState(false);
221
+ const [globalError, setGlobalError] = useState(null);
222
+ const inputRef = useRef(null);
223
+ const reactId = useId();
224
+ const normalizedBase = `/${basePath.replace(/^\/+|\/+$/g, "")}`;
225
+ const uploadUrl = `${normalizedBase}/${filetype}`;
226
+ const effectiveMax = maxFiles ?? (multiple ? Number.POSITIVE_INFINITY : 1);
227
+ const currentValue = value ?? [];
228
+ const appendKey = useCallback(
229
+ (key) => {
230
+ const next = [...currentValue, key];
231
+ onChange?.(next);
232
+ },
233
+ [currentValue, onChange]
234
+ );
235
+ const removeKey = useCallback(
236
+ (key) => {
237
+ const next = currentValue.filter((k) => k !== key);
238
+ onChange?.(next);
239
+ },
240
+ [currentValue, onChange]
241
+ );
242
+ const removeTrackerEntry = useCallback((id) => {
243
+ setFiles((prev) => prev.filter((f) => f.id !== id));
244
+ }, []);
245
+ const handleFiles = useCallback(
246
+ async (incoming) => {
247
+ if (disabled) return;
248
+ setGlobalError(null);
249
+ const list = Array.from(incoming);
250
+ if (list.length === 0) return;
251
+ const inFlight = files.filter((f) => f.status !== "error").length;
252
+ const remainingSlots = effectiveMax - currentValue.length - inFlight;
253
+ if (remainingSlots <= 0) {
254
+ setGlobalError(
255
+ `Maximum of ${effectiveMax} file${effectiveMax === 1 ? "" : "s"} reached`
256
+ );
257
+ return;
258
+ }
259
+ if (list.length > remainingSlots) {
260
+ setGlobalError(
261
+ `Only ${remainingSlots} more file${remainingSlots === 1 ? "" : "s"} allowed`
262
+ );
263
+ list.length = remainingSlots;
264
+ }
265
+ const accepted = [];
266
+ for (const file of list) {
267
+ const id = `${reactId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
268
+ if (!matchesAccept(file, accept)) {
269
+ const errEntry = {
270
+ id,
271
+ name: file.name,
272
+ size: file.size,
273
+ type: file.type,
274
+ progress: 0,
275
+ status: "error",
276
+ error: `${file.type || "file"} is not an accepted type`
277
+ };
278
+ accepted.push({ entry: errEntry, file });
279
+ onError?.(errEntry);
280
+ continue;
281
+ }
282
+ if (maxSize != null && file.size > maxSize) {
283
+ const errEntry = {
284
+ id,
285
+ name: file.name,
286
+ size: file.size,
287
+ type: file.type,
288
+ progress: 0,
289
+ status: "error",
290
+ error: `File is ${formatBytes2(file.size)} but max is ${formatBytes2(maxSize)}`
291
+ };
292
+ accepted.push({ entry: errEntry, file });
293
+ onError?.(errEntry);
294
+ continue;
295
+ }
296
+ accepted.push({
297
+ entry: {
298
+ id,
299
+ name: file.name,
300
+ size: file.size,
301
+ type: file.type,
302
+ progress: 0,
303
+ status: "pending"
304
+ },
305
+ file
306
+ });
307
+ }
308
+ setFiles((prev) => [...prev, ...accepted.map((a) => a.entry)]);
309
+ for (const { entry, file } of accepted) {
310
+ if (entry.status === "error") continue;
311
+ setFiles(
312
+ (prev) => prev.map(
313
+ (f) => f.id === entry.id ? { ...f, status: "uploading" } : f
314
+ )
315
+ );
316
+ try {
317
+ const result = await uploader(file, {
318
+ url: uploadUrl,
319
+ onProgress: (percent) => {
320
+ setFiles(
321
+ (prev) => prev.map(
322
+ (f) => f.id === entry.id ? { ...f, progress: percent } : f
323
+ )
324
+ );
325
+ }
326
+ });
327
+ setFiles(
328
+ (prev) => prev.map(
329
+ (f) => f.id === entry.id ? { ...f, status: "success", progress: 100, key: result.key } : f
330
+ )
331
+ );
332
+ appendKey(result.key);
333
+ setFiles((prev) => prev.filter((f) => f.id !== entry.id));
334
+ } catch (e) {
335
+ const message = e instanceof Error ? e.message : String(e);
336
+ const errEntry = {
337
+ ...entry,
338
+ status: "error",
339
+ error: message
340
+ };
341
+ setFiles(
342
+ (prev) => prev.map((f) => f.id === entry.id ? errEntry : f)
343
+ );
344
+ onError?.(errEntry);
345
+ }
346
+ }
347
+ },
348
+ [
349
+ accept,
350
+ appendKey,
351
+ currentValue.length,
352
+ disabled,
353
+ effectiveMax,
354
+ files,
355
+ maxSize,
356
+ onError,
357
+ reactId,
358
+ uploadUrl,
359
+ uploader
360
+ ]
361
+ );
362
+ const handleDrop = useCallback(
363
+ (e) => {
364
+ e.preventDefault();
365
+ setIsDragOver(false);
366
+ void handleFiles(e.dataTransfer.files);
367
+ },
368
+ [handleFiles]
369
+ );
370
+ const handleDragOver = useCallback(
371
+ (e) => {
372
+ e.preventDefault();
373
+ if (!disabled) setIsDragOver(true);
374
+ },
375
+ [disabled]
376
+ );
377
+ const handleDragLeave = useCallback(() => {
378
+ setIsDragOver(false);
379
+ }, []);
380
+ const handleClick = useCallback(() => {
381
+ if (disabled) return;
382
+ inputRef.current?.click();
383
+ }, [disabled]);
384
+ const handleInputChange = useCallback(
385
+ (e) => {
386
+ void handleFiles(e.target.files ?? []);
387
+ e.target.value = "";
388
+ },
389
+ [handleFiles]
390
+ );
391
+ return /* @__PURE__ */ jsxs2("div", { className, "data-cfast-upload-field": filetype, children: [
392
+ label != null ? /* @__PURE__ */ jsx5(
393
+ "label",
394
+ {
395
+ htmlFor: `${reactId}-input`,
396
+ style: { display: "block", marginBottom: 4 },
397
+ children: label
398
+ }
399
+ ) : null,
400
+ /* @__PURE__ */ jsx5(
401
+ "div",
402
+ {
403
+ role: "button",
404
+ tabIndex: disabled ? -1 : 0,
405
+ "aria-disabled": disabled,
406
+ "data-drag-over": isDragOver,
407
+ "data-testid": "upload-field-drop-zone",
408
+ onClick: handleClick,
409
+ onDrop: handleDrop,
410
+ onDragOver: handleDragOver,
411
+ onDragLeave: handleDragLeave,
412
+ onKeyDown: (e) => {
413
+ if (e.key === "Enter" || e.key === " ") {
414
+ e.preventDefault();
415
+ handleClick();
416
+ }
417
+ },
418
+ style: {
419
+ border: "2px dashed",
420
+ borderColor: isDragOver ? "#1976d2" : "#ccc",
421
+ borderRadius: 4,
422
+ padding: 16,
423
+ textAlign: "center",
424
+ cursor: disabled ? "not-allowed" : "pointer",
425
+ opacity: disabled ? 0.6 : 1
426
+ },
427
+ children: multiple ? "Drop files here or click to browse" : "Drop a file here or click to browse"
428
+ }
429
+ ),
430
+ /* @__PURE__ */ jsx5(
431
+ "input",
432
+ {
433
+ ref: inputRef,
434
+ id: `${reactId}-input`,
435
+ type: "file",
436
+ accept,
437
+ multiple,
438
+ disabled,
439
+ style: { display: "none" },
440
+ onChange: handleInputChange,
441
+ "data-testid": "upload-field-input"
442
+ }
443
+ ),
444
+ globalError != null ? /* @__PURE__ */ jsx5("div", { role: "alert", "data-testid": "upload-field-global-error", style: { color: "#d32f2f", marginTop: 8 }, children: globalError }) : null,
445
+ currentValue.length > 0 ? /* @__PURE__ */ jsx5(
446
+ "ul",
447
+ {
448
+ "data-testid": "upload-field-value-list",
449
+ style: { listStyle: "none", padding: 0, margin: "8px 0 0 0" },
450
+ children: currentValue.map((key) => /* @__PURE__ */ jsxs2(
451
+ "li",
452
+ {
453
+ "data-testid": "upload-field-value-item",
454
+ style: {
455
+ display: "flex",
456
+ alignItems: "center",
457
+ gap: 8,
458
+ padding: "4px 0"
459
+ },
460
+ children: [
461
+ /* @__PURE__ */ jsx5("span", { style: { flex: 1, fontSize: "0.85em" }, children: key }),
462
+ /* @__PURE__ */ jsx5(
463
+ "button",
464
+ {
465
+ type: "button",
466
+ onClick: () => removeKey(key),
467
+ disabled,
468
+ "aria-label": `Remove ${key}`,
469
+ style: {
470
+ background: "none",
471
+ border: "none",
472
+ cursor: disabled ? "not-allowed" : "pointer",
473
+ color: "#d32f2f"
474
+ },
475
+ children: "\xD7"
476
+ }
477
+ )
478
+ ]
479
+ },
480
+ key
481
+ ))
482
+ }
483
+ ) : null,
484
+ files.length > 0 ? /* @__PURE__ */ jsx5(
485
+ "ul",
486
+ {
487
+ "data-testid": "upload-field-tracker",
488
+ style: { listStyle: "none", padding: 0, margin: "8px 0 0 0" },
489
+ children: files.map((file) => /* @__PURE__ */ jsxs2(
490
+ "li",
491
+ {
492
+ "data-testid": "upload-field-tracker-item",
493
+ "data-status": file.status,
494
+ style: {
495
+ display: "flex",
496
+ flexDirection: "column",
497
+ gap: 4,
498
+ padding: "4px 0"
499
+ },
500
+ children: [
501
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [
502
+ /* @__PURE__ */ jsx5("span", { style: { flex: 1 }, children: file.name }),
503
+ /* @__PURE__ */ jsx5("span", { style: { color: "#666", fontSize: "0.85em" }, children: formatBytes2(file.size) }),
504
+ /* @__PURE__ */ jsx5(
505
+ "button",
506
+ {
507
+ type: "button",
508
+ onClick: () => removeTrackerEntry(file.id),
509
+ "aria-label": `Dismiss ${file.name}`,
510
+ style: {
511
+ background: "none",
512
+ border: "none",
513
+ cursor: "pointer",
514
+ color: "#666"
515
+ },
516
+ children: "\xD7"
517
+ }
518
+ )
519
+ ] }),
520
+ file.status === "uploading" ? /* @__PURE__ */ jsx5(
521
+ "progress",
522
+ {
523
+ "data-testid": "upload-field-progress",
524
+ value: file.progress,
525
+ max: 100,
526
+ style: { width: "100%" }
527
+ }
528
+ ) : null,
529
+ file.status === "error" && file.error != null ? /* @__PURE__ */ jsx5(
530
+ "div",
531
+ {
532
+ role: "alert",
533
+ "data-testid": "upload-field-file-error",
534
+ style: { color: "#d32f2f", fontSize: "0.85em" },
535
+ children: file.error
536
+ }
537
+ ) : null
538
+ ]
539
+ },
540
+ file.id
541
+ ))
542
+ }
543
+ ) : null
544
+ ] });
545
+ }
546
+
128
547
  // src/components/navigation-progress.tsx
129
548
  import { useNavigation } from "react-router";
130
- import { jsx as jsx5 } from "react/jsx-runtime";
549
+ import { jsx as jsx6 } from "react/jsx-runtime";
131
550
  function NavigationProgress({
132
551
  color = "#1976d2"
133
552
  }) {
@@ -136,7 +555,7 @@ function NavigationProgress({
136
555
  if (!isNavigating) {
137
556
  return null;
138
557
  }
139
- return /* @__PURE__ */ jsx5(
558
+ return /* @__PURE__ */ jsx6(
140
559
  "div",
141
560
  {
142
561
  role: "progressbar",
@@ -156,32 +575,32 @@ function NavigationProgress({
156
575
  }
157
576
 
158
577
  // src/components/app-shell.tsx
159
- import { Fragment, jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
578
+ import { Fragment, jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
160
579
  function AppShell({ children, sidebar, header }) {
161
580
  const Shell = useComponent("appShell");
162
- return /* @__PURE__ */ jsx6(Shell, { sidebar, header, children });
581
+ return /* @__PURE__ */ jsx7(Shell, { sidebar, header, children });
163
582
  }
164
583
  function AppShellSidebar({ items }) {
165
- return /* @__PURE__ */ jsx6("nav", { children: /* @__PURE__ */ jsx6("ul", { style: { listStyle: "none", padding: 0, margin: 0 }, children: items.map((item) => /* @__PURE__ */ jsx6(SidebarItem, { item }, item.to)) }) });
584
+ return /* @__PURE__ */ jsx7("nav", { children: /* @__PURE__ */ jsx7("ul", { style: { listStyle: "none", padding: 0, margin: 0 }, children: items.map((item) => /* @__PURE__ */ jsx7(SidebarItem, { item }, item.to)) }) });
166
585
  }
167
586
  function SidebarItem({ item }) {
168
587
  if (item.action) {
169
- return /* @__PURE__ */ jsx6(PermissionFilteredItem, { item });
588
+ return /* @__PURE__ */ jsx7(PermissionFilteredItem, { item });
170
589
  }
171
- return /* @__PURE__ */ jsx6("li", { children: /* @__PURE__ */ jsx6("a", { href: item.to, children: item.label }) });
590
+ return /* @__PURE__ */ jsx7("li", { children: /* @__PURE__ */ jsx7("a", { href: item.to, children: item.label }) });
172
591
  }
173
592
  function PermissionFilteredItem({ item }) {
174
593
  const status = useActionStatus(item.action);
175
594
  if (status.invisible || !status.permitted) {
176
595
  return null;
177
596
  }
178
- return /* @__PURE__ */ jsx6("li", { children: /* @__PURE__ */ jsx6("a", { href: item.to, children: item.label }) });
597
+ return /* @__PURE__ */ jsx7("li", { children: /* @__PURE__ */ jsx7("a", { href: item.to, children: item.label }) });
179
598
  }
180
599
  function AppShellHeader({
181
600
  children,
182
601
  userMenu
183
602
  }) {
184
- return /* @__PURE__ */ jsxs2(
603
+ return /* @__PURE__ */ jsxs3(
185
604
  "header",
186
605
  {
187
606
  style: {
@@ -192,7 +611,7 @@ function AppShellHeader({
192
611
  borderBottom: "1px solid #ddd"
193
612
  },
194
613
  children: [
195
- children ?? /* @__PURE__ */ jsx6(Fragment, {}),
614
+ children ?? /* @__PURE__ */ jsx7(Fragment, {}),
196
615
  userMenu ?? null
197
616
  ]
198
617
  }
@@ -203,7 +622,7 @@ AppShell.Header = AppShellHeader;
203
622
 
204
623
  // src/components/user-menu.tsx
205
624
  import { useCurrentUser } from "@cfast/auth/client";
206
- import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
625
+ import { jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
207
626
  function UserMenu({
208
627
  links = [],
209
628
  onSignOut
@@ -212,8 +631,8 @@ function UserMenu({
212
631
  if (!user) {
213
632
  return null;
214
633
  }
215
- return /* @__PURE__ */ jsxs3("div", { style: { position: "relative" }, children: [
216
- /* @__PURE__ */ jsx7(
634
+ return /* @__PURE__ */ jsxs4("div", { style: { position: "relative" }, children: [
635
+ /* @__PURE__ */ jsx8(
217
636
  AvatarWithInitials,
218
637
  {
219
638
  src: user.avatarUrl,
@@ -221,17 +640,17 @@ function UserMenu({
221
640
  size: "sm"
222
641
  }
223
642
  ),
224
- /* @__PURE__ */ jsxs3("div", { className: "user-menu-dropdown", children: [
225
- /* @__PURE__ */ jsxs3("div", { children: [
226
- /* @__PURE__ */ jsx7("strong", { children: user.name }),
227
- /* @__PURE__ */ jsx7("br", {}),
228
- /* @__PURE__ */ jsx7("small", { children: user.email })
643
+ /* @__PURE__ */ jsxs4("div", { className: "user-menu-dropdown", children: [
644
+ /* @__PURE__ */ jsxs4("div", { children: [
645
+ /* @__PURE__ */ jsx8("strong", { children: user.name }),
646
+ /* @__PURE__ */ jsx8("br", {}),
647
+ /* @__PURE__ */ jsx8("small", { children: user.email })
229
648
  ] }),
230
- user.roles.length > 0 ? /* @__PURE__ */ jsx7("div", { style: { display: "flex", gap: "4px", marginTop: "4px" }, children: user.roles.map((role) => /* @__PURE__ */ jsx7(RoleBadge, { role }, role)) }) : null,
649
+ user.roles.length > 0 ? /* @__PURE__ */ jsx8("div", { style: { display: "flex", gap: "4px", marginTop: "4px" }, children: user.roles.map((role) => /* @__PURE__ */ jsx8(RoleBadge, { role }, role)) }) : null,
231
650
  links.map(
232
- (link) => link.action ? /* @__PURE__ */ jsx7(PermissionFilteredLink, { link }, link.to) : /* @__PURE__ */ jsx7("a", { href: link.to, children: link.label }, link.to)
651
+ (link) => link.action ? /* @__PURE__ */ jsx8(PermissionFilteredLink, { link }, link.to) : /* @__PURE__ */ jsx8("a", { href: link.to, children: link.label }, link.to)
233
652
  ),
234
- onSignOut ? /* @__PURE__ */ jsx7(
653
+ onSignOut ? /* @__PURE__ */ jsx8(
235
654
  "button",
236
655
  {
237
656
  onClick: onSignOut,
@@ -247,7 +666,7 @@ function PermissionFilteredLink({ link }) {
247
666
  if (status.invisible || !status.permitted) {
248
667
  return null;
249
668
  }
250
- return /* @__PURE__ */ jsx7("a", { href: link.to, children: link.label });
669
+ return /* @__PURE__ */ jsx8("a", { href: link.to, children: link.label });
251
670
  }
252
671
  export {
253
672
  ActionButton,
@@ -282,6 +701,7 @@ export {
282
701
  TextField,
283
702
  ToastContext,
284
703
  UIPluginProvider,
704
+ UploadField,
285
705
  UrlField,
286
706
  UserMenu,
287
707
  createUIPlugin,
package/llms.txt CHANGED
@@ -74,11 +74,43 @@ useColumnInference(table: Record<string, unknown> | undefined, columns?: string[
74
74
  ```typescript
75
75
  DateField, BooleanField, NumberField, TextField,
76
76
  EmailField, UrlField, ImageField, FileField,
77
- RelationField, JsonField
77
+ RelationField, JsonField, UploadField
78
78
  fieldForColumn(columnMeta): ComponentType // maps Drizzle column type to field
79
79
  fieldsForTable(table): Record<string, ComponentType> // field map for whole table
80
80
  ```
81
81
 
82
+ ### UploadField (headless)
83
+
84
+ Controlled multi-file upload field built on `@cfast/storage`'s `POST
85
+ /uploads/:filetype` route. Unlike `DropZone` (which wraps a single
86
+ `useUpload` call), `UploadField` is form-friendly: it owns its own upload
87
+ state, enforces `maxFiles`, and reports successful uploads through the
88
+ controlled `value` / `onChange` pair.
89
+
90
+ ```tsx
91
+ import { UploadField } from "@cfast/ui";
92
+
93
+ <UploadField
94
+ filetype="productImages" // matches defineStorage key
95
+ basePath="/uploads" // matches storageRoutes() mount path
96
+ multiple // allow multiple files
97
+ maxFiles={10} // hard cap (default 1 when !multiple)
98
+ accept="image/*" // forwarded to <input accept>
99
+ maxSize={5 * 1024 * 1024} // optional client-side byte cap
100
+ value={keys} // string[] of R2 keys (controlled)
101
+ onChange={setKeys} // fires with updated keys
102
+ onError={(file) => ...} // per-file upload error callback
103
+ />
104
+ ```
105
+
106
+ Tests mock the POST endpoint by passing a custom `uploader` prop instead
107
+ of stubbing `XMLHttpRequest` globally. The `uploader` signature is:
108
+
109
+ ```ts
110
+ (file: File, opts: { url: string; onProgress: (pct: number) => void }) =>
111
+ Promise<{ key: string; size: number; type: string; url?: string }>
112
+ ```
113
+
82
114
  ### Plugin API
83
115
 
84
116
  ```typescript
@@ -157,3 +189,8 @@ useActionToast(composed.client, {
157
189
  - Forgetting `<ConfirmProvider>` -- `useConfirm()` and `ActionButton` with `confirmation` need it.
158
190
  - Passing raw data arrays to `DataTable` instead of a pagination result from `usePagination()`.
159
191
  - Using `whenForbidden="show"` on `ActionButton` when you mean `"disable"` -- `"show"` renders even when forbidden with no visual indicator.
192
+
193
+ ## See Also
194
+
195
+ - `@cfast/joy` -- Drop-in MUI Joy plugin that styles `@cfast/ui` headless components.
196
+ - `@cfast/actions` -- Provides the action descriptors consumed by `ActionButton`/`ActionForm`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Permission-aware React components with UI library plugins",
5
5
  "keywords": [
6
6
  "cfast",
@@ -36,25 +36,36 @@
36
36
  "publishConfig": {
37
37
  "access": "public"
38
38
  },
39
- "scripts": {
40
- "build": "tsup src/index.ts src/client.ts --format esm --dts",
41
- "dev": "tsup src/index.ts src/client.ts --format esm --dts --watch",
42
- "typecheck": "tsc --noEmit",
43
- "lint": "eslint src/",
44
- "test": "vitest run"
45
- },
46
39
  "peerDependencies": {
40
+ "@cfast/actions": ">=0.1.0 <0.3.0",
41
+ "@cfast/auth": ">=0.3.0 <0.5.0",
42
+ "@cfast/db": ">=0.3.0 <0.5.0",
43
+ "@cfast/pagination": ">=0.2.0 <0.4.0",
44
+ "@cfast/permissions": ">=0.3.0 <0.5.0",
45
+ "@cfast/storage": ">=0.1.0 <0.3.0",
47
46
  "react": ">=19",
48
47
  "react-dom": ">=19",
49
48
  "react-router": ">=7"
50
49
  },
51
- "dependencies": {
52
- "@cfast/actions": "workspace:*",
53
- "@cfast/auth": "workspace:*",
54
- "@cfast/db": "workspace:*",
55
- "@cfast/pagination": "workspace:*",
56
- "@cfast/permissions": "workspace:*",
57
- "@cfast/storage": "workspace:*"
50
+ "peerDependenciesMeta": {
51
+ "@cfast/actions": {
52
+ "optional": false
53
+ },
54
+ "@cfast/auth": {
55
+ "optional": false
56
+ },
57
+ "@cfast/db": {
58
+ "optional": false
59
+ },
60
+ "@cfast/pagination": {
61
+ "optional": false
62
+ },
63
+ "@cfast/permissions": {
64
+ "optional": false
65
+ },
66
+ "@cfast/storage": {
67
+ "optional": false
68
+ }
58
69
  },
59
70
  "devDependencies": {
60
71
  "@testing-library/react": "^16.3.0",
@@ -67,6 +78,19 @@
67
78
  "react-router": "^7.12.0",
68
79
  "tsup": "^8",
69
80
  "typescript": "^5.7",
70
- "vitest": "^4.1.0"
81
+ "vitest": "^4.1.0",
82
+ "@cfast/actions": "0.1.2",
83
+ "@cfast/auth": "0.4.0",
84
+ "@cfast/pagination": "0.2.0",
85
+ "@cfast/permissions": "0.4.0",
86
+ "@cfast/storage": "0.2.0",
87
+ "@cfast/db": "0.4.0"
88
+ },
89
+ "scripts": {
90
+ "build": "tsup src/index.ts src/client.ts --format esm --dts",
91
+ "dev": "tsup src/index.ts src/client.ts --format esm --dts --watch",
92
+ "typecheck": "tsc --noEmit",
93
+ "lint": "eslint src/",
94
+ "test": "vitest run"
71
95
  }
72
- }
96
+ }