Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Composable, headless Uppy UI components #5379

Open
2 tasks done
Murderlon opened this issue Aug 1, 2024 · 3 comments
Open
2 tasks done

Composable, headless Uppy UI components #5379

Murderlon opened this issue Aug 1, 2024 · 3 comments
Assignees
Labels

Comments

@Murderlon
Copy link
Member

Murderlon commented Aug 1, 2024

Initial checklist

  • I understand this is a feature request and questions should be posted in the Community Forum
  • I searched issues and couldn’t find anything (or linked relevant results below)

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

  • Single source of truth. No more manually writing framework wrappers for every component like we currently do.
  • Supports more frameworks than we currently do. We could target a bigger market for free.
  • Some big companies have created fairly complex design systems with it. Somewhat battle-tested, in other words.
  • You can also use Figma to output Mitosis code.

Cons

  • Vendor lock-in. If this ever becomes unmaintained we fully bought into this and a different technical approach would be a big undertaking at that point.
  • Limitations. Naturally, if you cross compile to many frameworks you have constraints. Luckily, you can make custom plugins per framework in case you need an escape hatch.

Uncertainties

  • React has useSyncExternalStore (used in useUppyState) to subscribe to third-party state inside the React lifecycle but we don't have that for other frameworks. How would other frameworks sync state with Uppy's internal state when using mitosis?

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

  • Works everywhere. Even if you don't use a front-end framework.
  • No vendor lock-in.
  • Single source of truth. You don't need any wrapper components, generated or manually, it's all just HTML and JS.
  • Potentially less work due to inspiration and/or using UIKit.

Cons

  • No first-class framework support. You have to learn and understand how this work with data attributes and CSS classes.
  • Most likely unforeseen limitations.

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
Copy link
Contributor

lakesare commented Aug 1, 2024

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
Copy link

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

@Murderlon
Copy link
Member Author

Murderlon commented Sep 25, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

6 participants