-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: "Headless" UI components (#136)
"Headless" UI React components `react-{keyring|uploader|uploads-list}` now all provide "headless" UI components modeled after https://headlessui.com/ The Big Idea with these is that they provide functionality and look like regular UI components but don't force developers into any particular markup or styling choices. They're very useful "lego" building blocks that work together to encapsulate the details of interacting with our services and let developer focus on building UI however they prefer to do so. Properties like `className` are passed along to the underlying component. Co-authored-by: Alan Shaw <[email protected]> Co-authored-by: Yusef Napora <[email protected]> Co-authored-by: Nathan Vander Wilt <[email protected]>
- Loading branch information
1 parent
afe756f
commit 46583e0
Showing
10 changed files
with
402 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
import React, { | ||
useState, createContext, useContext, useCallback, useMemo | ||
} from 'react' | ||
import { useKeyring, KeyringContextState, KeyringContextActions } from './providers/Keyring' | ||
|
||
export type AuthenticatorContextState = KeyringContextState & { | ||
/** | ||
* email to be used to "log in" | ||
*/ | ||
email?: string | ||
/** | ||
* has the authentication form been submitted? | ||
*/ | ||
submitted: boolean | ||
/** | ||
* A callback that can be passed to an `onSubmit` handler to | ||
* register a new space or log in using `email` | ||
*/ | ||
handleRegisterSubmit?: (e: React.FormEvent<HTMLFormElement>) => Promise<void> | ||
} | ||
|
||
export type AuthenticatorContextActions = KeyringContextActions & { | ||
/** | ||
* Set an email to be used to log in or register. | ||
*/ | ||
setEmail: React.Dispatch<React.SetStateAction<string>> | ||
} | ||
|
||
export type AuthenticatorContextValue = [ | ||
state: AuthenticatorContextState, | ||
actions: AuthenticatorContextActions | ||
] | ||
|
||
export const AuthenticatorContext = createContext<AuthenticatorContextValue>([ | ||
{ | ||
spaces: [], | ||
submitted: false | ||
}, | ||
{ | ||
setEmail: () => { throw new Error('missing set email function') }, | ||
loadAgent: async () => { }, | ||
unloadAgent: async () => { }, | ||
resetAgent: async () => { }, | ||
createSpace: async () => { throw new Error('missing keyring context provider') }, | ||
setCurrentSpace: async () => { }, | ||
registerSpace: async () => { }, | ||
cancelRegisterSpace: () => { }, | ||
getProofs: async () => [] | ||
} | ||
]) | ||
|
||
/** | ||
* Top level component of the headless Authenticator. | ||
* | ||
* Must be used inside a KeyringProvider. | ||
* | ||
* 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 { | ||
const [state, actions] = useKeyring() | ||
const { createSpace, registerSpace } = actions | ||
const [email, setEmail] = useState('') | ||
const [submitted, setSubmitted] = useState(false) | ||
|
||
const handleRegisterSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => { | ||
e.preventDefault() | ||
setSubmitted(true) | ||
try { | ||
await createSpace() | ||
await registerSpace(email) | ||
} catch (err: any) { | ||
throw new Error('failed to register', { cause: err }) | ||
} finally { | ||
setSubmitted(false) | ||
} | ||
}, [setSubmitted, createSpace, registerSpace]) | ||
|
||
const value = useMemo<AuthenticatorContextValue>(() => [ | ||
{ ...state, email, submitted, handleRegisterSubmit }, | ||
{ ...actions, setEmail } | ||
], [state, actions, email, submitted, handleRegisterSubmit]) | ||
return ( | ||
<AuthenticatorContext.Provider {...props} value={value} /> | ||
) | ||
} | ||
|
||
/** | ||
* 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) { | ||
const [{ handleRegisterSubmit }] = useAuthenticator() | ||
return ( | ||
<form {...props} onSubmit={handleRegisterSubmit} /> | ||
) | ||
} | ||
|
||
/** | ||
* 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) { | ||
const [{ email }, { setEmail }] = useAuthenticator() | ||
return ( | ||
<input {...props} type='email' value={email} onChange={e => setEmail(e.target.value)} /> | ||
) | ||
} | ||
|
||
/** | ||
* 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) { | ||
const [, { cancelRegisterSpace }] = useAuthenticator() | ||
return ( | ||
<button {...props} onClick={() => { cancelRegisterSpace() }} /> | ||
) | ||
} | ||
|
||
/** | ||
* Use the scoped authenticator context state from a parent `Authenticator`. | ||
*/ | ||
export function useAuthenticator (): AuthenticatorContextValue { | ||
return useContext(AuthenticatorContext) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * from './providers/Keyring' | ||
export * from './Authenticator' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
import React, { useContext, useMemo, createContext, useState } from 'react' | ||
import { Link, Version } from 'multiformats' | ||
import { CARMetadata, UploaderContextState, UploaderContextActions } from '@w3ui/uploader-core' | ||
import { useUploader } from './providers/Uploader' | ||
|
||
export enum Status { | ||
Idle = 'idle', | ||
Uploading = 'uploading', | ||
Failed = 'failed', | ||
Succeeded = 'succeeded' | ||
} | ||
|
||
export type UploaderComponentContextState = UploaderContextState & { | ||
/** | ||
* A string indicating the status of this component - can be 'uploading', 'done' or ''. | ||
*/ | ||
status: Status | ||
/** | ||
* Error thrown by upload process. | ||
*/ | ||
error?: Error | ||
/** | ||
* a File to be uploaded | ||
*/ | ||
file?: File | ||
/** | ||
* A callback that can be passed to an `onSubmit` handler to | ||
* upload `file` to web3.storage via the w3up API | ||
*/ | ||
handleUploadSubmit?: (e: Event) => Promise<void> | ||
/** | ||
* The CID of a successful upload | ||
*/ | ||
dataCID?: Link<unknown, number, number, Version> | ||
/** | ||
* Shards of a DAG uploaded to web3.storage | ||
*/ | ||
storedDAGShards: CARMetadata[] | ||
} | ||
|
||
export type UploaderComponentContextActions = UploaderContextActions & { | ||
/** | ||
* Set a file to be uploaded to web3.storage. The file will be uploaded | ||
* when `handleUploadSubmit` is called. | ||
*/ | ||
setFile: React.Dispatch<React.SetStateAction<File | undefined>> | ||
} | ||
|
||
export type UploaderComponentContextValue = [ | ||
state: UploaderComponentContextState, | ||
actions: UploaderComponentContextActions | ||
] | ||
|
||
const UploaderComponentContext = createContext<UploaderComponentContextValue>([ | ||
{ | ||
status: Status.Idle, | ||
storedDAGShards: [] | ||
}, | ||
{ | ||
setFile: () => { throw new Error('missing set file function') }, | ||
uploadFile: async () => { throw new Error('missing uploader context provider') }, | ||
uploadDirectory: async () => { throw new Error('missing uploader context provider') } | ||
} | ||
]) | ||
|
||
export interface UploaderComponentProps { | ||
children?: JSX.Element | ||
} | ||
|
||
/** | ||
* Top level component of the headless Uploader. | ||
* | ||
* Designed to be used with Uploader.Form and Uploader.Input | ||
* to easily create a custom component for uploading files to | ||
* web3.storage. | ||
*/ | ||
export const Uploader = ({ | ||
children | ||
}: UploaderComponentProps): JSX.Element => { | ||
const [uploaderState, uploaderActions] = useUploader() | ||
const [file, setFile] = useState<File>() | ||
const [dataCID, setDataCID] = useState<Link<unknown, number, number, Version>>() | ||
const [status, setStatus] = useState(Status.Idle) | ||
const [error, setError] = useState() | ||
|
||
const handleUploadSubmit = async (e: Event): Promise<void> => { | ||
e.preventDefault() | ||
if (file != null) { | ||
try { | ||
setError(undefined) | ||
setStatus(Status.Uploading) | ||
const cid = await uploaderActions.uploadFile(file) | ||
setDataCID(cid) | ||
setStatus(Status.Succeeded) | ||
} catch (err: any) { | ||
setError(err) | ||
setStatus(Status.Failed) | ||
} | ||
} | ||
} | ||
|
||
const uploaderComponentContextValue = useMemo<UploaderComponentContextValue>(() => [ | ||
{ ...uploaderState, file, dataCID, status, error, handleUploadSubmit }, | ||
{ ...uploaderActions, setFile } | ||
], [uploaderState, file, dataCID, status, error, handleUploadSubmit, uploaderActions, setFile]) | ||
|
||
return ( | ||
<UploaderComponentContext.Provider value={uploaderComponentContextValue}> | ||
{children} | ||
</UploaderComponentContext.Provider> | ||
) | ||
} | ||
|
||
/** | ||
* 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 => { | ||
const [, { setFile }] = useContext(UploaderComponentContext) | ||
return ( | ||
<input {...props} type='file' onChange={e => setFile(e.target.files?.[0])} /> | ||
) | ||
} | ||
|
||
/** | ||
* 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 => { | ||
const [{ handleUploadSubmit }] = useContext(UploaderComponentContext) | ||
return ( | ||
<form {...props} onSubmit={handleUploadSubmit}> | ||
{children} | ||
</form> | ||
) | ||
} | ||
|
||
/** | ||
* Use the scoped uploader context state from a parent `Uploader`. | ||
*/ | ||
export function useUploaderComponent (): UploaderComponentContextValue { | ||
return useContext(UploaderComponentContext) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export type { Service, CARMetadata } from '@w3ui/uploader-core' | ||
export { uploadFile, uploadDirectory } from '@w3ui/uploader-core' | ||
export * from './providers/Uploader' | ||
export * from './Uploader' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.