File manager

Enterprise file-browser composed from mp-splitter, mp-treeview, and mp-datatable Lit web components. Tree on the left, file list on the right, breadcrumb above, toolbar with search, view-mode toggle, and file operations. The live demo below is fully interactive: drag-drop files from your OS, rename inline, multi-select, cut/copy/paste, switch the language, simulate read-only permissions, or toggle on the lazy-load tree mode.

Keyboard shortcuts
  • In icon view all four arrows plus Home / End rove between the cards: the grid reflows on resize and so has no stable 2-D geometry, which means β†’ / ↓ both mean β€œnext card” and ← / ↑ both mean β€œprevious”. In details view row focus is the datatable's own ↑ / ↓ handling, not the file manager's.
  • Enter β€” same as double-click: navigate into a folder, or emit (nodeOpen) for a file
  • Ctrl/⌘+click β€” toggle one item, and Shift+click β€” extend from the last-selected item. Both need Selection set to multiple; Shift+click only ever adds to the selection, it never trims it.
  • F2 β€” rename, but only with exactly one item selected
  • Del β€” delete the selection after a confirmation dialog; does nothing on an empty selection
  • Ctrl+X / Ctrl+C / Ctrl+V β€” cut / copy / paste (⌘ also works). Cut and copy need a selection, paste needs a filled clipboard, and each needs its operation to be enabled.
  • Ctrl+Shift+N β€” new folder in the current location
  • Shift+F10 / ContextMenu β€” open the context menu on the first selected item. With nothing selected, neither key does anything.
  • Long-press (600 ms) on touch β€” opens the context menu
  • In the context menu: focus moves into it on open, ↑ / ↓ / Home / End rove between the items, Esc closes it
  • While the rename editor is open it owns every key β€” Enter commits, Esc abandons the edit, and none of the shortcuts above fire

Integration guide for B2B portals

The component is intentionally event-driven: it renders the UX and emits intents, leaving every consumer to wire the backend, dialog style, and toast system of their own product. The recipes below cover the seven integration points a typical admin portal needs.

1. Operation handler (rename / delete / new folder / paste)

Every mutating action fires (operation) with a discriminated-union payload. The component is fire-and-forget β€” your handler runs the backend call, optionally marks the affected rows as pending, and reports failures through reportError() (which re-fires (errorReported) for your toast bus).

  
    
      
        // Mark rows busy while the backend processes the change, then clear
        
    
      
        // on completion. Failures route through reportError() β†’ (errorReported).
        
    
      
        onOperation(op: OperationEventDetail, fm: BsFileManagerComponent) {
        
    
      
          if (op.kind === 'rename') {
        
    
      
            fm.markPending(op.nodeId, 'rename');
        
    
      
            this.api.renameNode(op.nodeId, op.newName).then(
        
    
      
              () => { /* update local nodes signal */ },
        
    
      
              err => fm.reportError(err.message, op.nodeId),
        
    
      
            ).finally(() => fm.clearPending(op.nodeId));
        
    
      
          }
        
    
      
          // …handle 'delete', 'new-folder', 'paste' the same way.
        
    
      
        }
        
    

2. Upload pipeline with progress

Drag-drop and the touch upload button both fire (uploadRequest) carrying pre-registered UploadEntry IDs. Drive your real upload (XHR / fetch / S3 SDK), then push percentage updates into the component via reportUploadProgress(id, pct, status?). The uploads signal on the component reflects the in-flight set so you can render a progress UI wherever you like (the live demo above renders it directly below the file-manager).

  
    
      
        // The (uploadRequest) event carries pre-registered UploadEntry IDs.
        
    
      
        // Drive your real XHR / fetch upload, then push progress into the WC
        
    
      
        // so it can render the in-flight bar wherever your app prefers.
        
    
      
        onUpload(req: UploadRequestEventDetail, fm: BsFileManagerComponent) {
        
    
      
          for (const entry of req.uploads) {
        
    
      
            const xhr = new XMLHttpRequest();
        
    
      
            xhr.open('POST', '/api/upload');
        
    
      
            xhr.upload.addEventListener('progress', e =>
        
    
      
              fm.reportUploadProgress(entry.id, Math.round((e.loaded / e.total) * 100), 'uploading'));
        
    
      
            xhr.addEventListener('load', () =>
        
    
      
              fm.reportUploadProgress(entry.id, 100, 'done'));
        
    
      
            xhr.addEventListener('error', () =>
        
    
      
              fm.reportUploadProgress(entry.id, 0, 'error', 'Network error'));
        
    
      
            const fd = new FormData();
        
    
      
            fd.append('file', entry.file);
        
    
      
            xhr.send(fd);
        
    
      
          }
        
    
      
        }
        
    

3. Custom dialogs (replacing window.confirm / window.prompt)

Most enterprise apps want their own modal styling instead of the browser dialogs. Wire a DialogResolver to your modal service (typically a thin wrapper around bs-modal). When unset, the component falls back to the native dialogs so basic usage still works.

  
    
      
        // Replace the native window.confirm / window.prompt with a bs-modal
        
    
      
        // (or your design system's dialog). The resolver returns a Promise
        
    
      
        // resolving to a boolean (for confirm) or string|null (for prompt).
        
    
      
        dialogResolver: DialogResolver = req => {
        
    
      
          if (req.kind === 'confirm') {
        
    
      
            return this.modalService.confirm({ message: req.message }); // your wrapper
        
    
      
          }
        
    
      
          return this.modalService.prompt({ label: req.label, defaultValue: req.defaultValue });
        
    
      
        };
        
    
      
        
        
    
      
        // template:
        
    
      
        // <bs-file-manager [dialogResolver]="dialogResolver" ...></bs-file-manager>
        
    

4. Conflict resolution on paste / upload

When a paste or upload would overwrite a same-name entry in the target folder, the component calls conflictResolver once per conflict. Returning 'skip' filters the source out of the operation; 'rename' includes the consumer's chosen newName in the resulting (operation) / (uploadRequest) payload. Unset = silent replace (preserves the v1 behaviour).

  
    
      
        // Asked once per same-name overlap when the user pastes or drops
        
    
      
        // files. Returning 'skip' filters that source out of the operation.
        
    
      
        conflictResolver: ConflictResolver = async req => {
        
    
      
          const choice = await this.modalService.open(ConflictDialogComponent, {
        
    
      
            existingNode: req.existingNode,
        
    
      
            incomingName: req.incomingName,
        
    
      
          });
        
    
      
          // choice is 'replace' | 'skip' | { action: 'rename', newName: string }
        
    
      
          return choice;
        
    
      
        };
        
    

5. Lazy-loaded tree

Real file systems aren't materialisable up front. Bind a loadChildren async callback and seed [nodes] with only the top-level entries. The component marks every unloaded folder with a chevron, shows a spinner during the fetch, and merges the returned children back into its store β€” no consumer-side coordination beyond returning the right data.

  
    
      
        // Lazy tree: seed only the top level; load children on first expand.
        
    
      
        // The component merges loaded nodes into its local store and re-renders.
        
    
      
        nodes = signal<FileSystemNode[]>(this.api.getRoots());
        
    
      
        
        
    
      
        loadChildren = (parentId: string) =>
        
    
      
          this.api.listChildren(parentId); // returns Promise<FileSystemNode[]>
        
    
      
        
        
    
      
        // template:
        
    
      
        // <bs-file-manager [nodes]="nodes()" [loadChildren]="loadChildren"></bs-file-manager>
        
    

6. Internationalisation

Every visible string + ARIA label routes through FileManagerMessages. Pass a partial overrides object via [messages]; unset keys fall back to DEFAULT_FILE_MANAGER_MESSAGES (English). Locale switching works at runtime β€” the demo above uses three locales.

  
    
      
        // Overrides are partial β€” unset keys fall back to English defaults.
        
    
      
        messages = computed<Partial<FileManagerMessages>>(() => ({
        
    
      
          home: this.t('fm.home'),
        
    
      
          newFolder: this.t('fm.new_folder'),
        
    
      
          delete: this.t('fm.delete'),
        
    
      
          deleteConfirm: n => this.t('fm.delete_confirm', { count: n }),
        
    
      
          // …all keys your locale needs
        
    
      
        }));
        
    

7. Per-node permissions

Beyond the global [allowOperations] input, every FileSystemNode can carry its own allowOperations partial. The toolbar / context-menu buttons gate against the worst case across the current selection (a multi-row delete is disabled if any selected row is locked).

  
    
      
        // Mark a folder read-only by setting allowOperations on the node itself.
        
    
      
        // Each falsy key disables that operation for the node; unset keys fall
        
    
      
        // back to the global [allowOperations] input.
        
    
      
        const node: FileSystemNode = {
        
    
      
          id: 'shared/marketing',
        
    
      
          parentId: null,
        
    
      
          name: 'Marketing (read-only)',
        
    
      
          type: 'folder',
        
    
      
          allowOperations: { rename: false, delete: false, cut: false, newFolder: false },
        
    
      
        };
        
    

8. Error reporting β†’ toast bus

Failures from your backend never need to reach into the component's UI directly. Call fm.reportError(message, nodeId?) from your handler and listen on (errorReported) at the template level β€” that's where you wire the toast / snackbar.

  
    
      
        // Surface errors from your backend through the (errorReported) event.
        
    
      
        // The component fires this whenever reportError() is called β€” wire
        
    
      
        // it to your toast / snackbar service.
        
    
      
        onError(detail: { kind: string; message: string; nodeId?: string }) {
        
    
      
          this.toastService.danger(detail.message);
        
    
      
        }
        
    
      
        
        
    
      
        // template:
        
    
      
        // <bs-file-manager (errorReported)="onError($event)"></bs-file-manager>
        
    

Checklist for production

  • Backend wiring β€” handle every kind in onOperation + the upload pipeline.
  • Toasts β€” listen on (errorReported); route to your snackbar.
  • Dialogs β€” wire dialogResolver to bs-modal if you don't want native browser dialogs.
  • Conflicts β€” wire conflictResolver if "silent overwrite" isn't your spec.
  • Permissions β€” surface per-node allowOperations from your auth layer.
  • i18n β€” pipe your translation service through [messages].
  • Scale β€” bind [loadChildren] if folder cardinality is unbounded.
  • Touch β€” no extra wiring; long-press + upload button activate automatically on coarse pointers.