@vryzel/file-next-ui 0.5.0 → 0.5.2

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/dist/index.d.cts CHANGED
@@ -200,6 +200,15 @@ interface FileExplorerProps {
200
200
  onSelect: (file: FileNode) => void;
201
201
  };
202
202
  readonly protectedIds?: ReadonlyArray<string>;
203
+ /**
204
+ * Per-item rename/delete locks, resolved by the consumer (permissions,
205
+ * business rules). Locked actions are disabled in the context menu, blocked
206
+ * on F2/Delete, and excluded from the selection toast.
207
+ */
208
+ readonly itemLocks?: (file: FileNode) => {
209
+ rename?: boolean;
210
+ delete?: boolean;
211
+ };
203
212
  readonly labels?: Partial<ExplorerLabels>;
204
213
  }
205
214
  declare function FileExplorer(props: FileExplorerProps): React.ReactElement;
@@ -280,6 +289,8 @@ interface ExplorerContextMenuProps {
280
289
  readonly mode?: "browse" | "trash";
281
290
  readonly actions: ExplorerContextMenuActions;
282
291
  readonly protectedItem?: boolean;
292
+ readonly renameLocked?: boolean;
293
+ readonly deleteLocked?: boolean;
283
294
  readonly onClose: () => void;
284
295
  readonly onPreview?: (file: FileNode) => void;
285
296
  readonly onDownload?: (file: FileNode) => void;
package/dist/index.d.ts CHANGED
@@ -200,6 +200,15 @@ interface FileExplorerProps {
200
200
  onSelect: (file: FileNode) => void;
201
201
  };
202
202
  readonly protectedIds?: ReadonlyArray<string>;
203
+ /**
204
+ * Per-item rename/delete locks, resolved by the consumer (permissions,
205
+ * business rules). Locked actions are disabled in the context menu, blocked
206
+ * on F2/Delete, and excluded from the selection toast.
207
+ */
208
+ readonly itemLocks?: (file: FileNode) => {
209
+ rename?: boolean;
210
+ delete?: boolean;
211
+ };
203
212
  readonly labels?: Partial<ExplorerLabels>;
204
213
  }
205
214
  declare function FileExplorer(props: FileExplorerProps): React.ReactElement;
@@ -280,6 +289,8 @@ interface ExplorerContextMenuProps {
280
289
  readonly mode?: "browse" | "trash";
281
290
  readonly actions: ExplorerContextMenuActions;
282
291
  readonly protectedItem?: boolean;
292
+ readonly renameLocked?: boolean;
293
+ readonly deleteLocked?: boolean;
283
294
  readonly onClose: () => void;
284
295
  readonly onPreview?: (file: FileNode) => void;
285
296
  readonly onDownload?: (file: FileNode) => void;
package/dist/index.js CHANGED
@@ -1039,6 +1039,8 @@ function ExplorerContextMenu(props) {
1039
1039
  mode = "browse",
1040
1040
  actions,
1041
1041
  protectedItem = false,
1042
+ renameLocked = false,
1043
+ deleteLocked = false,
1042
1044
  onClose,
1043
1045
  onPreview,
1044
1046
  onDownload,
@@ -1087,7 +1089,7 @@ function ExplorerContextMenu(props) {
1087
1089
  swallowClickThrough();
1088
1090
  onClose();
1089
1091
  };
1090
- const itemClass = "flex w-full min-h-11 cursor-pointer items-center gap-2 rounded-[10px] px-3 py-2.5 text-left text-sm outline-none hover:bg-accent disabled:opacity-40 sm:min-h-0 sm:px-2.5 sm:py-1.5";
1092
+ const itemClass = "flex w-full min-h-11 cursor-pointer items-center gap-2 rounded-[10px] px-3 py-2.5 text-left text-sm outline-none hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40 sm:min-h-0 sm:px-2.5 sm:py-1.5";
1091
1093
  return /* @__PURE__ */ jsx(
1092
1094
  "div",
1093
1095
  {
@@ -1164,13 +1166,24 @@ function ExplorerContextMenu(props) {
1164
1166
  close();
1165
1167
  extraFileAction.onSelect(target.file);
1166
1168
  }, children: extraFileAction.label }) : null,
1167
- onRename ? /* @__PURE__ */ jsxs("button", { type: "button", role: "menuitem", className: itemClass, onClick: () => {
1168
- close();
1169
- onRename(target.file);
1170
- }, children: [
1171
- /* @__PURE__ */ jsx(Pencil, { "aria-hidden": "true", className: "size-4" }),
1172
- labels.rename
1173
- ] }) : null,
1169
+ onRename ? /* @__PURE__ */ jsxs(
1170
+ "button",
1171
+ {
1172
+ type: "button",
1173
+ role: "menuitem",
1174
+ className: itemClass,
1175
+ disabled: renameLocked,
1176
+ onClick: () => {
1177
+ if (renameLocked) return;
1178
+ close();
1179
+ onRename(target.file);
1180
+ },
1181
+ children: [
1182
+ /* @__PURE__ */ jsx(Pencil, { "aria-hidden": "true", className: "size-4" }),
1183
+ labels.rename
1184
+ ]
1185
+ }
1186
+ ) : null,
1174
1187
  onCopy ? /* @__PURE__ */ jsxs("button", { type: "button", role: "menuitem", className: itemClass, onClick: () => {
1175
1188
  close();
1176
1189
  onCopy(target.file);
@@ -1209,10 +1222,10 @@ function ExplorerContextMenu(props) {
1209
1222
  {
1210
1223
  type: "button",
1211
1224
  role: "menuitem",
1212
- disabled: protectedItem,
1225
+ disabled: protectedItem || deleteLocked,
1213
1226
  className: cn(itemClass, "text-destructive"),
1214
1227
  onClick: () => {
1215
- if (protectedItem) return;
1228
+ if (protectedItem || deleteLocked) return;
1216
1229
  close();
1217
1230
  if (onRequestDelete) onRequestDelete(target.file);
1218
1231
  else void actions.deleteFile({ id: target.file.id });
@@ -1964,10 +1977,15 @@ function FileExplorerInner(props) {
1964
1977
  onPreview,
1965
1978
  onDownload,
1966
1979
  extraFileAction,
1967
- protectedIds
1980
+ protectedIds,
1981
+ itemLocks
1968
1982
  } = props;
1969
1983
  const labels = useExplorerLabels();
1970
1984
  const protectedSet = useMemo(() => new Set(protectedIds ?? []), [protectedIds]);
1985
+ const locksFor = useCallback(
1986
+ (file) => itemLocks?.(file) ?? {},
1987
+ [itemLocks]
1988
+ );
1971
1989
  const explorer = useFileExplorer({
1972
1990
  listFiles: ((input) => listFiles({ parentId: input.parentId, limit: PAGE_SIZE })),
1973
1991
  tenantId,
@@ -1989,6 +2007,7 @@ function FileExplorerInner(props) {
1989
2007
  const browseCursor = useRef(void 0);
1990
2008
  const overlayCursor = useRef(void 0);
1991
2009
  const loadingMoreRef = useRef(false);
2010
+ const openingFolderIdRef = useRef(null);
1992
2011
  const overlayMoreRef = useRef(false);
1993
2012
  const browseGen = useRef(0);
1994
2013
  const [overlayFiles, setOverlayFiles] = useState(null);
@@ -2061,6 +2080,7 @@ function FileExplorerInner(props) {
2061
2080
  setLoadingMore(false);
2062
2081
  return;
2063
2082
  }
2083
+ if (mode === "reset") openingFolderIdRef.current = null;
2064
2084
  if (result.ok) {
2065
2085
  browseCursor.current = result.value.nextCursor;
2066
2086
  setBrowseHasMore(Boolean(result.value.nextCursor));
@@ -2180,14 +2200,18 @@ function FileExplorerInner(props) {
2180
2200
  () => [...sourceFiles].sort((a, b) => compareFiles(a, b, sortKey, sortDir)),
2181
2201
  [sourceFiles, sortKey, sortDir]
2182
2202
  );
2203
+ const refreshSeen = useRef(refreshKey);
2183
2204
  useEffect(() => {
2184
- if (refreshKey === void 0) return;
2205
+ if (refreshKey === void 0 || refreshSeen.current === refreshKey) return;
2206
+ refreshSeen.current = refreshKey;
2185
2207
  void reload();
2186
- }, [refreshKey]);
2208
+ }, [refreshKey, reload]);
2187
2209
  const handleActivate = useCallback(
2188
2210
  (file) => {
2189
2211
  if (file.id === DRAFT_FOLDER_ID) return;
2190
2212
  if (file.kind === "folder") {
2213
+ if (openingFolderIdRef.current === file.id) return;
2214
+ openingFolderIdRef.current = file.id;
2191
2215
  onOpenFolder?.(file);
2192
2216
  return;
2193
2217
  }
@@ -2318,6 +2342,7 @@ function FileExplorerInner(props) {
2318
2342
  }
2319
2343
  if (event.key === "F2" && selected && !trashOpen) {
2320
2344
  event.preventDefault();
2345
+ if (locksFor(selected).rename) return;
2321
2346
  setRenameTarget(selected);
2322
2347
  return;
2323
2348
  }
@@ -2327,7 +2352,8 @@ function FileExplorerInner(props) {
2327
2352
  if (actions.purgeNode) setPurgeTargets(selectedFiles);
2328
2353
  return;
2329
2354
  }
2330
- if (selectedFiles.some((file) => protectedSet.has(file.id))) return;
2355
+ if (selectedFiles.some((file) => protectedSet.has(file.id) || locksFor(file).delete))
2356
+ return;
2331
2357
  setDeleteTargets(selectedFiles);
2332
2358
  return;
2333
2359
  }
@@ -2341,6 +2367,7 @@ function FileExplorerInner(props) {
2341
2367
  clearSelection,
2342
2368
  copyToClipboard,
2343
2369
  handleActivate,
2370
+ locksFor,
2344
2371
  pasteClipboard,
2345
2372
  protectedSet,
2346
2373
  selected,
@@ -2379,6 +2406,7 @@ function FileExplorerInner(props) {
2379
2406
  ...sortedFiles
2380
2407
  ];
2381
2408
  }, [draftFolderName, parentId, sortedFiles, tenantId]);
2409
+ const listing = overlayLoading || pageStatus === "loading";
2382
2410
  const renamingId = draftFolderName != null ? DRAFT_FOLDER_ID : renameTarget?.id ?? null;
2383
2411
  const openUpload = () => uploadInputRef.current?.click();
2384
2412
  return /* @__PURE__ */ jsxs(
@@ -2443,79 +2471,95 @@ function FileExplorerInner(props) {
2443
2471
  }
2444
2472
  )
2445
2473
  ] }),
2446
- /* @__PURE__ */ jsx("div", { ref: scrollRef, className: "min-h-0 flex-1 overflow-y-auto overflow-x-auto overscroll-x-contain", children: (overlayLoading || pageStatus === "loading") && displayFiles.length === 0 ? /* @__PURE__ */ jsx("p", { className: "px-4 py-12 text-center font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: labels.loading }) : overlayError || pageStatus === "error" && pageError ? /* @__PURE__ */ jsx("div", { className: "border-b border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive", children: (overlayError ?? pageError)?.message ?? labels.folderCreateFailed }) : displayFiles.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "flex min-h-full flex-col justify-center px-4 py-16 text-center", children: [
2447
- /* @__PURE__ */ jsx("p", { className: "font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: trashOpen ? labels.emptyTrash : query.trim() ? labels.noMatches : labels.emptyFolder }),
2448
- trashOpen ? /* @__PURE__ */ jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: labels.trashRetention }) : !query.trim() ? /* @__PURE__ */ jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: labels.dropHint }) : null
2449
- ] }) : explorer.view === "list" ? /* @__PURE__ */ jsx(
2450
- "div",
2451
- {
2452
- onClick: (event) => {
2453
- if (event.target.closest("[data-file-id]")) return;
2454
- clearSelection();
2455
- },
2456
- children: /* @__PURE__ */ jsx(
2457
- ExplorerListView,
2458
- {
2459
- files: displayFiles,
2460
- protectedIds: protectedSet,
2461
- selectedIds,
2462
- onSelect: handleSelect,
2463
- onActivate: handleActivate,
2464
- onContextMenu: handleContextMenu,
2465
- onDragStart: explorer.beginDrag,
2466
- onDragEnd: explorer.endDrag,
2467
- onDrop: handleDropOnFolder,
2468
- draggingId: explorer.draggingId,
2469
- draggingIds,
2470
- dropTargetId: explorer.dropTargetId,
2471
- onDragOverRow: explorer.setDropTarget,
2472
- sortKey,
2473
- sortDirection: sortDir,
2474
- onSortChange: handleSortChange,
2475
- renamingId,
2476
- onRenameCommit: commitRename,
2477
- onRenameCancel: cancelRename,
2478
- scrollRef,
2479
- hasMore,
2480
- loadingMore: loadingMore || overlayMore,
2481
- onLoadMore: loadNext
2482
- }
2483
- )
2484
- }
2485
- ) : /* @__PURE__ */ jsx(
2474
+ /* @__PURE__ */ jsxs(
2486
2475
  "div",
2487
2476
  {
2488
- onClick: (event) => {
2489
- if (event.target.closest("[data-file-id]")) return;
2490
- clearSelection();
2491
- },
2492
- children: /* @__PURE__ */ jsx(
2493
- ExplorerGridView,
2494
- {
2495
- files: displayFiles,
2496
- protectedIds: protectedSet,
2497
- selectedIds,
2498
- onSelect: handleSelect,
2499
- onActivate: handleActivate,
2500
- onContextMenu: handleContextMenu,
2501
- onDragStart: explorer.beginDrag,
2502
- onDragEnd: explorer.endDrag,
2503
- onDrop: handleDropOnFolder,
2504
- draggingId: explorer.draggingId,
2505
- draggingIds,
2506
- dropTargetId: explorer.dropTargetId,
2507
- onDragOverRow: explorer.setDropTarget,
2508
- renamingId,
2509
- onRenameCommit: commitRename,
2510
- onRenameCancel: cancelRename,
2511
- scrollRef,
2512
- hasMore,
2513
- loadingMore: loadingMore || overlayMore,
2514
- onLoadMore: loadNext
2515
- }
2516
- )
2477
+ ref: scrollRef,
2478
+ "aria-busy": listing,
2479
+ className: cn(
2480
+ "min-h-0 flex-1 overflow-y-auto overflow-x-auto overscroll-x-contain",
2481
+ // Stale rows stay mounted while the next listing loads, so dim them and
2482
+ // swallow clicks instead of letting the user re-open the same folder.
2483
+ listing && displayFiles.length > 0 && "pointer-events-none opacity-60"
2484
+ ),
2485
+ children: [
2486
+ listing && displayFiles.length > 0 ? /* @__PURE__ */ jsx("div", { "aria-hidden": true, className: "h-0.5 w-full overflow-hidden bg-muted", children: /* @__PURE__ */ jsx("div", { className: "h-full w-1/3 animate-pulse bg-primary" }) }) : null,
2487
+ listing && displayFiles.length === 0 ? /* @__PURE__ */ jsx("p", { className: "px-4 py-12 text-center font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: labels.loading }) : overlayError || pageStatus === "error" && pageError ? /* @__PURE__ */ jsx("div", { className: "border-b border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive", children: (overlayError ?? pageError)?.message ?? labels.folderCreateFailed }) : displayFiles.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "flex min-h-full flex-col justify-center px-4 py-16 text-center", children: [
2488
+ /* @__PURE__ */ jsx("p", { className: "font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: trashOpen ? labels.emptyTrash : query.trim() ? labels.noMatches : labels.emptyFolder }),
2489
+ trashOpen ? /* @__PURE__ */ jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: labels.trashRetention }) : !query.trim() ? /* @__PURE__ */ jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: labels.dropHint }) : null
2490
+ ] }) : explorer.view === "list" ? /* @__PURE__ */ jsx(
2491
+ "div",
2492
+ {
2493
+ onClick: (event) => {
2494
+ if (event.target.closest("[data-file-id]")) return;
2495
+ clearSelection();
2496
+ },
2497
+ children: /* @__PURE__ */ jsx(
2498
+ ExplorerListView,
2499
+ {
2500
+ files: displayFiles,
2501
+ protectedIds: protectedSet,
2502
+ selectedIds,
2503
+ onSelect: handleSelect,
2504
+ onActivate: handleActivate,
2505
+ onContextMenu: handleContextMenu,
2506
+ onDragStart: explorer.beginDrag,
2507
+ onDragEnd: explorer.endDrag,
2508
+ onDrop: handleDropOnFolder,
2509
+ draggingId: explorer.draggingId,
2510
+ draggingIds,
2511
+ dropTargetId: explorer.dropTargetId,
2512
+ onDragOverRow: explorer.setDropTarget,
2513
+ sortKey,
2514
+ sortDirection: sortDir,
2515
+ onSortChange: handleSortChange,
2516
+ renamingId,
2517
+ onRenameCommit: commitRename,
2518
+ onRenameCancel: cancelRename,
2519
+ scrollRef,
2520
+ hasMore,
2521
+ loadingMore: loadingMore || overlayMore,
2522
+ onLoadMore: loadNext
2523
+ }
2524
+ )
2525
+ }
2526
+ ) : /* @__PURE__ */ jsx(
2527
+ "div",
2528
+ {
2529
+ onClick: (event) => {
2530
+ if (event.target.closest("[data-file-id]")) return;
2531
+ clearSelection();
2532
+ },
2533
+ children: /* @__PURE__ */ jsx(
2534
+ ExplorerGridView,
2535
+ {
2536
+ files: displayFiles,
2537
+ protectedIds: protectedSet,
2538
+ selectedIds,
2539
+ onSelect: handleSelect,
2540
+ onActivate: handleActivate,
2541
+ onContextMenu: handleContextMenu,
2542
+ onDragStart: explorer.beginDrag,
2543
+ onDragEnd: explorer.endDrag,
2544
+ onDrop: handleDropOnFolder,
2545
+ draggingId: explorer.draggingId,
2546
+ draggingIds,
2547
+ dropTargetId: explorer.dropTargetId,
2548
+ onDragOverRow: explorer.setDropTarget,
2549
+ renamingId,
2550
+ onRenameCommit: commitRename,
2551
+ onRenameCancel: cancelRename,
2552
+ scrollRef,
2553
+ hasMore,
2554
+ loadingMore: loadingMore || overlayMore,
2555
+ onLoadMore: loadNext
2556
+ }
2557
+ )
2558
+ }
2559
+ )
2560
+ ]
2517
2561
  }
2518
- ) }),
2562
+ ),
2519
2563
  /* @__PURE__ */ jsxs("footer", { className: "flex shrink-0 items-center justify-between gap-3 border-t border-border px-4 py-2", children: [
2520
2564
  /* @__PURE__ */ jsxs("span", { className: "font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground", children: [
2521
2565
  /* @__PURE__ */ jsxs("span", { className: "text-primary tabular-nums", children: [
@@ -2536,6 +2580,8 @@ function FileExplorerInner(props) {
2536
2580
  target: menu,
2537
2581
  mode: trashOpen ? "trash" : "browse",
2538
2582
  protectedItem: menu?.kind === "item" ? protectedSet.has(menu.file.id) : false,
2583
+ renameLocked: menu?.kind === "item" ? Boolean(locksFor(menu.file).rename) : false,
2584
+ deleteLocked: menu?.kind === "item" ? Boolean(locksFor(menu.file).delete) : false,
2539
2585
  extraFileAction,
2540
2586
  actions: {
2541
2587
  deleteFile: async (input) => {
@@ -2570,7 +2616,10 @@ function FileExplorerInner(props) {
2570
2616
  ExplorerSelectionToast,
2571
2617
  {
2572
2618
  files: selectedFiles,
2573
- protectedIds: protectedSet,
2619
+ protectedIds: /* @__PURE__ */ new Set([
2620
+ ...protectedSet,
2621
+ ...selectedFiles.filter((file) => locksFor(file).delete).map((file) => file.id)
2622
+ ]),
2574
2623
  trashOpen,
2575
2624
  onDownload,
2576
2625
  onCopy: trashOpen ? void 0 : () => copyToClipboard(selectedFiles),