Skip to content

Commit

Permalink
feat: support as prop in uploader component (storacha#236)
Browse files Browse the repository at this point in the history
follow the pattern set by HeadlessUI and others of supporting the very
useful "as" property in the uploader component. this:

1. lets users override default tag types (eg, changing a `button` to an
`a` tag)
2. lets users provide custom components to be used as the root element
of our headless components
3. lets users elide a root tag entirely by passing `Fragment` to `as`

See
https://headlessui.com/react/menu#rendering-a-different-element-for-a-component
for more documentation on how this is commonly used

This introduces a new dependency on ariakit-react-utils
(https://ariakit.org/) to avoid reproducing the intricate puzzle of
types in the `createComponent` and `createElement` functions from that
library. I suspect other utilities will be useful as we improve the
accessibility defaults of this library so I think this dependency is
worth adding, but we could copy/paste the specific code we need with
proper attribution if we want to avoid pulling in the whole library.

resolves storacha#235

Co-authored-by: Alan Shaw <alan.shaw@protocol.ai>
Co-authored-by: Yusef Napora <yusef@napora.org>
Co-authored-by: Nathan Vander Wilt <natevw@yahoo.com>
  • Loading branch information
4 people authored Jan 13, 2023
1 parent 46583e0 commit c802e99
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 124 deletions.
3 changes: 2 additions & 1 deletion packages/react-keyring/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
},
"homepage": "https://github.com/web3-storage/w3ui/tree/main/packages/react-keyring",
"dependencies": {
"@w3ui/keyring-core": "workspace:^"
"@w3ui/keyring-core": "workspace:^",
"ariakit-react-utils": "0.17.0-next.27"
},
"devDependencies": {
"@ucanto/interface": "^4.0.3",
Expand Down
64 changes: 44 additions & 20 deletions packages/react-keyring/src/Authenticator.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, {
useState, createContext, useContext, useCallback, useMemo
} from 'react'
import type { As, Component, Props, Options } from 'ariakit-react-utils'
import type { ChangeEvent } from 'react'

import React, { Fragment, useState, createContext, useContext, useCallback, useMemo, useEffect } from 'react'
import { createComponent, createElement } from 'ariakit-react-utils'
import { useKeyring, KeyringContextState, KeyringContextActions } from './providers/Keyring'

export type AuthenticatorContextState = KeyringContextState & {
Expand Down Expand Up @@ -49,6 +51,16 @@ export const AuthenticatorContext = createContext<AuthenticatorContextValue>([
}
])

export const AgentLoader = ({ children }: { children: JSX.Element }): JSX.Element => {
const [, { loadAgent }] = useKeyring()
// eslint-disable-next-line
useEffect(() => { loadAgent() }, []) // load agent - once.
return children
}

export type AuthenticatorRootOptions<T extends As = typeof Fragment> = Options<T>
export type AuthenticatorRootProps<T extends As = typeof Fragment> = Props<AuthenticatorRootOptions<T>>

/**
* Top level component of the headless Authenticator.
*
Expand All @@ -57,7 +69,7 @@ export const AuthenticatorContext = createContext<AuthenticatorContextValue>([
* Designed to be used by Authenticator.Form, Authenticator.EmailInput
* and others to make it easy to implement authentication UI.
*/
export function Authenticator (props: any): JSX.Element {
export const AuthenticatorRoot: Component<AuthenticatorRootProps> = createComponent((props) => {
const [state, actions] = useKeyring()
const { createSpace, registerSpace } = actions
const [email, setEmail] = useState('')
Expand All @@ -74,59 +86,71 @@ export function Authenticator (props: any): JSX.Element {
} finally {
setSubmitted(false)
}
}, [setSubmitted, createSpace, registerSpace])
}, [email, setSubmitted, createSpace, registerSpace])

const value = useMemo<AuthenticatorContextValue>(() => [
{ ...state, email, submitted, handleRegisterSubmit },
{ ...actions, setEmail }
], [state, actions, email, submitted, handleRegisterSubmit])
return (
<AuthenticatorContext.Provider {...props} value={value} />
<AgentLoader>
<AuthenticatorContext.Provider value={value}>
{createElement(Fragment, props)}
</AuthenticatorContext.Provider>
</AgentLoader>
)
}
})

export type FormOptions<T extends As = 'form'> = Options<T>
export type FormProps<T extends As = 'form'> = Props<FormOptions<T>>

/**
* Form component for the headless Authenticator.
*
* A `form` designed to work with `Authenticator`. Any passed props will
* be passed along to the `form` component.
*/
Authenticator.Form = function Form (props: any) {
export const Form: Component<FormProps> = createComponent((props) => {
const [{ handleRegisterSubmit }] = useAuthenticator()
return (
<form {...props} onSubmit={handleRegisterSubmit} />
createElement('form', { ...props, onSubmit: handleRegisterSubmit })
)
}
})

export type EmailInputOptions<T extends As = 'input'> = Options<T>
export type EmailInputProps<T extends As = 'input'> = Props<EmailInputOptions<T>>

/**
* Input component for the headless Uploader.
*
* An email `input` designed to work with `Authenticator.Form`. Any passed props will
* be passed along to the `input` component.
*/
Authenticator.EmailInput = function EmailInput (props: any) {
export const EmailInput: Component<EmailInputProps> = createComponent(props => {
const [{ email }, { setEmail }] = useAuthenticator()
return (
<input {...props} type='email' value={email} onChange={e => setEmail(e.target.value)} />
)
}
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value), [])
return createElement('input', { ...props, type: 'email', value: email, onChange })
})

export type CancelButtonOptions<T extends As = 'button'> = Options<T>
export type CancelButtonProps<T extends As = 'button'> = Props<CancelButtonOptions<T>>

/**
* A button that will cancel space registration.
*
* A `button` designed to work with `Authenticator.Form`. Any passed props will
* be passed along to the `button` component.
*/
Authenticator.CancelButton = function CancelButton (props: any) {
export const CancelButton: Component<CancelButtonProps> = createComponent((props) => {
const [, { cancelRegisterSpace }] = useAuthenticator()
return (
<button {...props} onClick={() => { cancelRegisterSpace() }} />
)
}
return createElement('button', { ...props, onClick: cancelRegisterSpace })
})

/**
* Use the scoped authenticator context state from a parent `Authenticator`.
*/
export function useAuthenticator (): AuthenticatorContextValue {
return useContext(AuthenticatorContext)
}

export const Authenticator = Object.assign(AuthenticatorRoot, { Form, EmailInput, CancelButton })
3 changes: 2 additions & 1 deletion packages/react-uploader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@
},
"homepage": "https://github.com/web3-storage/w3ui/tree/main/packages/react-uploader",
"dependencies": {
"@w3ui/uploader-core": "workspace:^",
"@w3ui/react-keyring": "workspace:^",
"@w3ui/uploader-core": "workspace:^",
"@web3-storage/capabilities": "^2.0.0",
"ariakit-react-utils": "0.17.0-next.27",
"multiformats": "^10.0.2"
},
"peerDependencies": {
Expand Down
48 changes: 27 additions & 21 deletions packages/react-uploader/src/Uploader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import React, { useContext, useMemo, createContext, useState } from 'react'
import type { As, Component, Props, Options } from 'ariakit-react-utils'
import type { ChangeEvent } from 'react'

import React, { useContext, useMemo, useCallback, createContext, useState, Fragment } from 'react'
import { createComponent, createElement } from 'ariakit-react-utils'
import { Link, Version } from 'multiformats'
import { CARMetadata, UploaderContextState, UploaderContextActions } from '@w3ui/uploader-core'
import { useUploader } from './providers/Uploader'
Expand Down Expand Up @@ -63,9 +67,8 @@ const UploaderComponentContext = createContext<UploaderComponentContextValue>([
}
])

export interface UploaderComponentProps {
children?: JSX.Element
}
export type UploaderRootOptions<T extends As = typeof Fragment> = Options<T>
export type UploaderRootProps<T extends As = typeof Fragment> = Props<UploaderRootOptions<T>>

/**
* Top level component of the headless Uploader.
Expand All @@ -74,9 +77,7 @@ export interface UploaderComponentProps {
* to easily create a custom component for uploading files to
* web3.storage.
*/
export const Uploader = ({
children
}: UploaderComponentProps): JSX.Element => {
export const UploaderRoot: Component<UploaderRootProps> = createComponent((props) => {
const [uploaderState, uploaderActions] = useUploader()
const [file, setFile] = useState<File>()
const [dataCID, setDataCID] = useState<Link<unknown, number, number, Version>>()
Expand Down Expand Up @@ -106,42 +107,47 @@ export const Uploader = ({

return (
<UploaderComponentContext.Provider value={uploaderComponentContextValue}>
{children}
{createElement(Fragment, props)}
</UploaderComponentContext.Provider>
)
}
})

export type InputOptions<T extends As = 'input'> = Options<T>
export type InputProps<T extends As = 'input'> = Props<InputOptions<T>>

/**
* Input component for the headless Uploader.
*
* A file `input` designed to work with `Uploader`. Any passed props will
* be passed along to the `input` component.
*/
Uploader.Input = (props: any): JSX.Element => {
export const Input: Component<InputProps> = createComponent((props) => {
const [, { setFile }] = useContext(UploaderComponentContext)
return (
<input {...props} type='file' onChange={e => setFile(e.target.files?.[0])} />
)
}
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setFile(e?.target?.files?.[0])
}, [setFile])
return createElement('input', { ...props, type: 'file', onChange })
})

export type FormOptions<T extends As = 'form'> = Options<T>
export type FormProps<T extends As = 'form'> = Props<FormOptions<T>>

/**
* Form component for the headless Uploader.
*
* A `form` designed to work with `Uploader`. Any passed props will
* be passed along to the `form` component.
*/
Uploader.Form = ({ children, ...props }: { children: React.ReactNode } & any): JSX.Element => {
export const Form: Component<FormProps> = createComponent((props) => {
const [{ handleUploadSubmit }] = useContext(UploaderComponentContext)
return (
<form {...props} onSubmit={handleUploadSubmit}>
{children}
</form>
)
}
return createElement('form', { ...props, onSubmit: handleUploadSubmit })
})

/**
* Use the scoped uploader context state from a parent `Uploader`.
*/
export function useUploaderComponent (): UploaderComponentContextValue {
return useContext(UploaderComponentContext)
}

export const Uploader = Object.assign(UploaderRoot, { Input, Form })
5 changes: 3 additions & 2 deletions packages/react-uploads-list/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@
},
"homepage": "https://github.com/web3-storage/w3ui/tree/main/packages/react-uploads-list",
"dependencies": {
"@w3ui/uploads-list-core": "workspace:^",
"@w3ui/react-keyring": "workspace:^",
"@web3-storage/capabilities": "^2.0.0"
"@w3ui/uploads-list-core": "workspace:^",
"@web3-storage/capabilities": "^2.0.0",
"ariakit-react-utils": "0.17.0-next.27"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
Expand Down
68 changes: 43 additions & 25 deletions packages/react-uploads-list/src/UploadsList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import React, { createContext, useContext, useMemo, useCallback } from 'react'
import { UploadsListContextState, UploadsListContextActions } from '@w3ui/uploads-list-core'
import type { As, Component, Props, Options, RenderProp } from 'ariakit-react-utils'
import type { UploadsListContextState, UploadsListContextActions } from '@w3ui/uploads-list-core'

import React, { Fragment, createContext, useContext, useMemo, useCallback, useEffect } from 'react'
import { createComponent, createElement } from 'ariakit-react-utils'
import { useUploadsList } from './providers/UploadsList'

export type UploadsListComponentContextState = UploadsListContextState & {
Expand All @@ -12,7 +15,7 @@ export type UploadsListComponentContextActions = UploadsListContextActions & {

export type UploadsListComponentContextValue = [
state: UploadsListComponentContextState,
actions: UploadsListContextActions
actions: UploadsListComponentContextActions
]

export const UploadsListComponentContext = createContext<UploadsListComponentContextValue>([
Expand All @@ -35,13 +38,13 @@ export const UploadsListComponentContext = createContext<UploadsListComponentCon
}
])

export type UploadsListComponentChildrenProps = [
state: UploadsListContextState,
actions: UploadsListComponentContextActions
]

export interface UploadsListComponentProps {
children?: (props: UploadsListComponentChildrenProps) => React.ReactNode
export type UploadsListRootOptions = Options<typeof Fragment>
export type UploadsListRenderProps = Omit<Props<UploadsListRootOptions>, 'children'> & {
uploadsList?: UploadsListComponentContextValue
}
export type UploadsListRootProps = Omit<Props<UploadsListRootOptions>, 'children'> & {
uploadsList?: UploadsListComponentContextValue
children?: React.ReactNode | RenderProp<UploadsListRenderProps>
}

/**
Expand All @@ -50,58 +53,73 @@ export interface UploadsListComponentProps {
* Designed to be used with UploadsList.NextButton,
* Uploader.ReloadButton, et al to easily create a
* custom component for listing uploads to a web3.storage space.
*
* Always renders as a Fragment and does not support the `as` property.
*/
export const UploadsList = ({ children }: UploadsListComponentProps): JSX.Element => {
export const UploadsListRoot = (props: UploadsListRootProps): JSX.Element => {
const [state, actions] = useUploadsList()
const contextValue = useMemo<UploadsListComponentChildrenProps>(
const contextValue = useMemo<UploadsListComponentContextValue>(
() => ([state, actions]),
[state, actions])
const { children, ...childlessProps } = props
let renderedChildren: React.ReactNode
if (Boolean(children) && (typeof children === 'function')) {
renderedChildren = children({ ...childlessProps, uploadsList: contextValue })
} else {
renderedChildren = children as React.ReactNode
}
useEffect(() => {
// load the first page of results asynchronously
void actions.next()
}, [])
return (
<UploadsListComponentContext.Provider value={contextValue}>
{(typeof children === 'function')
? (
children(contextValue)
)
: (
children
)}
{renderedChildren}
</UploadsListComponentContext.Provider>
)
}

export type NextButtonOptions<T extends As = 'button'> = Options<T>
export type NextButtonProps<T extends As = 'button'> = Props<NextButtonOptions<T>>

/**
* Button that loads the next page of results.
*
* A 'button' designed to work with `UploadsList`. Any passed props will
* be passed along to the `button` component.
*/
UploadsList.NextButton = (props: any) => {
export const NextButton: Component<NextButtonProps> = createComponent((props: any) => {
const [, { next }] = useContext(UploadsListComponentContext)
const onClick = useCallback((e: React.MouseEvent) => {
e.preventDefault()
void next()
}, [next])
return <button {...props} onClick={onClick} />
}
return createElement('button', { ...props, onClick })
})

export type ReloadButtonOptions<T extends As = 'button'> = Options<T>
export type ReloadButtonProps<T extends As = 'button'> = Props<ReloadButtonOptions<T>>

/**
* Button that reloads an `UploadsList`.
*
* A 'button' designed to work with `UploadsList`. Any passed props will
* be passed along to the `button` component.
*/
UploadsList.ReloadButton = (props: any) => {
export const ReloadButton: Component<ReloadButtonProps> = createComponent((props: any) => {
const [, { reload }] = useContext(UploadsListComponentContext)
const onClick = useCallback((e: React.MouseEvent) => {
e.preventDefault()
void reload()
}, [reload])
return <button onClick={onClick} {...props} />
}
return createElement('button', { ...props, onClick })
})

/**
* Use the scoped uploads list context state from a parent `UploadsList`.
*/
export function useUploadsListComponent (): UploadsListComponentContextValue {
return useContext(UploadsListComponentContext)
}

export const UploadsList = Object.assign(UploadsListRoot, { NextButton, ReloadButton })
Loading

0 comments on commit c802e99

Please sign in to comment.