transloadit / uppy

The next open source file uploader for web browsers :dog:
https://uppy.io
MIT License
29.14k stars 2.01k forks source link

Composable, headless Uppy UI components #5379

Open Murderlon opened 3 months ago

Murderlon commented 3 months ago

Initial checklist

Problem

One size fits no one

Developers starting with Uppy are forced to choose between the full-featured, bundle size heavy, and non-customizable dashboard or the overly barebones drag-drop.

After years of speaking to developers on GitHub, the community forum, and with Transloadit customers – the reality seems to be that majority of people have their needs fall somewhere in between dashboard and drag-drop. Countless issues have been posted about them wanting mostly X but doing Y different for their use case.

@uppy/dashboard has tried to accommodate for some of these requests over the years which introduced overly specific "toggles", such as showLinkToFileUploadResult, showProgressDetails, hideUploadButton, hideRetryButton, hidePauseResumeButton, hideCancelButton, hideProgressAfterFinish, showRemoveButtonAfterComplete, disableStatusBar, disableInformer, and disableThumbnailGenerator.

Continuing down this path is not maintainable nor will we ever reach a set of "toggles" at this granularity level to support a wide range of use cases and unique requirements.

Fake promise of modular UI components

We built status-bar, progress-bar, image-editor, thumbnail-generator, informer, and provider-views as separate plugins, communicating on a first glance these are standalone components, but they are tightly coupled to dashboard. It's not impossible to use them separately, but this is discouraged, undocumented, and unclear which features won't work outside of dashboard.

Modern expectations

Since Uppy's conception 9 years ago the front-end landscape has significantly changed. Uppy is even built with a "vanilla" first approach because that was still popular at the time over front-end frameworks.

These days developers have high expectations for choosing a library. In a world where everything is component driven, OSS UI libraries are expected to offer truly composable building blocks, which are light on bundle size, and ideally with accessibility kept in mind.

For Uppy to stay relevant in the coming years a major chance is needed in how we let users built their uploading UI.

Solution

At a glance

Composable, headless Uppy UI components. These would be highly granular and without loosing support for our existing frameworks (Angular, Svelte, React, Vue).

Good examples of headless UI libraries:

It basically boils down to three things, which all work with declarative APIs:

  1. Have "trigger" components, usually buttons
  2. Have components which reveal on trigger
  3. Using a component directly uses the HTML/styles defined us while passing children allows you to compose it yourself while maintaining the same logic

For example:

export function PopoverDemo() {
  return (
    <Popover>
      <PopoverTrigger>
        {/* Not passing children would render a default button */}
        <Button>Open popover</Button>
      </PopoverTrigger>
      <PopoverContent className="w-80">
        {/* your content */}
      </PopoverContent>
    </Popover>
  )
}

Continuing with React as an example, it would mean instead of doing the all-or-nothing approach as we currently have:

function Component() {
  const [uppy] = useState(() => new Uppy())
  return <Dashboard uppy={uppy} />
}

...you would compose only UI elements you need. In the case of this pseudo-code example, the OS file picker, Google Drive, and an added file list with thumbnails.

function Component() {
  const [uppy] = React.useState(() => new Uppy().use(GoogleDrive));
  const files = useUppyState(uppy, (state) => state.files);
  // Create a UI based on errors
  const errors = useUppyState(uppy, (state) => state.errors);
  // ...or toast error notifications
  useUppyEvent(uppy, "upload-error", createErrorToast);

  return (
    <UppyContext uppy={uppy}>
      <Providers defaultValue="add-files">
        <ProviderContent value="add-files">
          {/* Drop your files anywhere */}
          <DragDropArea>
            <ProviderGrid>
              <ProviderTrigger value="my-device">
                <MyDeviceIcon />
                <Button>My Device</Button>
              </ProviderTrigger>

              <ProviderTrigger value="google-drive">
                {/* Put your own icon, style it different, whatever */}
                <GoogleDriveIcon />
                <button>Google Drive</button>
              </ProviderTrigger>
            </ProviderGrid>
          </DragDropArea>
        </ProviderContent>

        <ProviderContent value="google-drive">
          {/* Makes the child components aware which provider data to look for */}
          <RemoteProviderContext providerId="GoogleDrive">
            <RemoteProvider>
              <RemoteProviderLogin>
                <RemoteProviderLoginTrigger>
                  <GoogleDriveIcon />
                  <Button>Login</Button>
                </RemoteProviderLoginTrigger>
              </RemoteProviderLogin>

              {/* After successful OAuth */}
              <RemoteProviderContent>
                {/*
                 * Table with checkboxes (multi-select), thumbnails,
                 * and "select x" status bar. Should probably be broken up too
                 * to make it customizable, but it's a bit harder because
                 * of the abstracted complexity in here
                 * (and I don't want to make this example too big)
                 */}
                <RemoteFilesTable />
              </RemoteProviderContent>
            </RemoteProvider>
          </RemoteProviderContext>
        </ProviderContent>

        <ProviderContent value="file-list">
          <ProviderTrigger value="add-files">
            <Button>Add more files</Button>
          </ProviderTrigger>

          <FileGrid>
            {files.map((file) => (
              <FileCard>
                <FileCardThumbnail file={file} />
                <p>{file.name}</p>
              </FileCard>
            ))}
          </FileGrid>

          <StatusBar>
            {/* Abstracts pre, uploading, and post-processing progresss */}
            <ProgressBar files={files} />
            <EstimatedTime files={files} />
            <PauseResumeButton />
            <CancelButton />
          </StatusBar>
        </ProviderContent>
      </Providers>
    </UppyContext>
  );
}

What would this mean?

The long-term vision would be that this could replace dashboard, drag-drop, status-bar, progress-bar, informer, provider-views, and file-input in favor of highly granular components and one or two preconfigured compositions by us. The first phase could mean keeping all plugins around and slowly building these components. But it's better to try to build a dashboard-like experience with these components to 1) dogfood yourself and see what's needed and 2) eventually replace the monolith dashboard with a preconfigured composition.

Challenges

  1. Finding the right balance of granularity. You want to allow flexibility for almost all use cases but abstract enough to keep the integration code from exploding. And does every component have a default render if no children are provider? Or only some?
  2. Supporting multiple frameworks. Creating headless UI libraries is already challenging but we also must maintain support for Angular, Svelte, React, and Vue.
  3. Making internal state easily accessible. Let's say you want to create a table based on the remote files from Google Drive or a table for the files already added. You can't do this without looping over some state and creating rows yourself. In React we have useUppyState because it's hard to integrate/sync external state into the state of a framework. We don't have this for other frameworks. Uppy's state also tends to be clumsy. For instance, there is no way to know in which state (idle, uploading, error, post-processing, etc) Uppy is in because it's not a finite state machine. This limits UI building.

Potential technical approaches

Mitosis

Mitosis provides a unified development experience across all frameworks, enabling you to build components in a single codebase and compile them to React, Vue, Angular, Svelte, Solid, Alpine, Qwik, and more.

Pros

Cons

Uncertainties

HTML-first

Franken UI proves that you can turn shadcn/ui (React only) into a framework agnostic component library while maintaining the same flexibility. It's mostly just a styling wrapper (to match shadcn styles) around UIKit.

How that would like inside React for tabs component:

import '@uppy/components/switcher/index.js'
import '@uppy/components/switcher/styles.js'

function Component() {
  return (
   <>
      <ul uppy-tabs>
          <li><a href="#">Item</a></li>
          <li><a href="#">Item</a></li>
          <li><a href="#">Item</a></li>
      </ul>

      <div class="uppy-tabs">
          {/* by default switches on the top elements in the same order */}
          <div>Hello!</div>
          <div>Hello again!</div>
          <div>Bazinga!</div>
      </div>
   </>
  );
};

Pros

Cons

How to move forward from here

The only way to find out is creating some proof of concepts and see the limitations first hand.

lakesare commented 3 months ago

Agreed with all points, thank you for summing it up so thoroughly!

One other library that we might glean inspiration from is https://mui.com/material-ui/react-pagination/#custom-icons, overriding their ui has always been a good experience for me.

leftmove commented 1 month ago

Is this a thing yet? Are there any libraries that have tried this?

Murderlon commented 1 month ago

No this is a massive undertaking that hasn't been started yet. We're also lower than ever on core maintainers so don't expect it soon.