🚸(frontend) improve prejoin UX
- Always enable camera/mic by default (like Google Meet) - Fix video state transitions and add visual feedback - Simplify form using React Aria components - Reduce shadow intensity for better visual balance
This commit is contained in:
committed by
aleb_the_flash
parent
1b52d76168
commit
4347d87f33
@@ -1,25 +1,18 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
ParticipantPlaceholder,
|
|
||||||
usePersistentUserChoices,
|
usePersistentUserChoices,
|
||||||
usePreviewTracks,
|
usePreviewTracks,
|
||||||
type LocalUserChoices,
|
type LocalUserChoices,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { log } from '@livekit/components-core'
|
|
||||||
import { defaultUserChoices } from '@livekit/components-core'
|
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { useUser } from '@/features/auth'
|
import { useMemo, useEffect, useRef, useState } from 'react'
|
||||||
import React from 'react'
|
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||||
import {
|
|
||||||
facingModeFromLocalTrack,
|
|
||||||
LocalVideoTrack,
|
|
||||||
Track,
|
|
||||||
} from 'livekit-client'
|
|
||||||
import { H } from '@/primitives/H'
|
import { H } from '@/primitives/H'
|
||||||
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
||||||
import { Field } from '@/primitives/Field'
|
import { Field } from '@/primitives/Field'
|
||||||
import { Button } from '@/primitives'
|
import { Form } from '@/primitives'
|
||||||
|
import { HStack, VStack } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const onError = (e: Error) => console.error('ERROR', e)
|
const onError = (e: Error) => console.error('ERROR', e)
|
||||||
|
|
||||||
@@ -28,92 +21,47 @@ export const Join = ({
|
|||||||
}: {
|
}: {
|
||||||
onSubmit: (choices: LocalUserChoices) => void
|
onSubmit: (choices: LocalUserChoices) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||||
const { user } = useUser()
|
|
||||||
const defaults: Partial<LocalUserChoices> = { username: user?.full_name }
|
|
||||||
const persistUserChoices = true
|
|
||||||
const joinLabel = t('join.joinLabel')
|
|
||||||
const userLabel = t('join.usernameLabel')
|
|
||||||
|
|
||||||
const [userChoices, setUserChoices] = React.useState(defaultUserChoices)
|
|
||||||
|
|
||||||
// TODO: Remove and pipe `defaults` object directly into `usePersistentUserChoices` once we fully switch from type `LocalUserChoices` to `UserChoices`.
|
|
||||||
const partialDefaults: Partial<LocalUserChoices> = {
|
|
||||||
...(defaults.audioDeviceId !== undefined && {
|
|
||||||
audioDeviceId: defaults.audioDeviceId,
|
|
||||||
}),
|
|
||||||
...(defaults.videoDeviceId !== undefined && {
|
|
||||||
videoDeviceId: defaults.videoDeviceId,
|
|
||||||
}),
|
|
||||||
...(defaults.audioEnabled !== undefined && {
|
|
||||||
audioEnabled: defaults.audioEnabled,
|
|
||||||
}),
|
|
||||||
...(defaults.videoEnabled !== undefined && {
|
|
||||||
videoEnabled: defaults.videoEnabled,
|
|
||||||
}),
|
|
||||||
...(defaults.username !== undefined && { username: defaults.username }),
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userChoices: initialUserChoices,
|
userChoices: initialUserChoices,
|
||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveAudioInputEnabled,
|
|
||||||
saveVideoInputDeviceId,
|
saveVideoInputDeviceId,
|
||||||
saveVideoInputEnabled,
|
|
||||||
saveUsername,
|
saveUsername,
|
||||||
} = usePersistentUserChoices({
|
} = usePersistentUserChoices({})
|
||||||
defaults: partialDefaults,
|
|
||||||
preventSave: !persistUserChoices,
|
|
||||||
preventLoad: !persistUserChoices,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initialize device settings
|
const [audioDeviceId, setAudioDeviceId] = useState<string>(
|
||||||
const [audioEnabled, setAudioEnabled] = React.useState<boolean>(
|
|
||||||
initialUserChoices.audioEnabled
|
|
||||||
)
|
|
||||||
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(
|
|
||||||
initialUserChoices.videoEnabled
|
|
||||||
)
|
|
||||||
const [audioDeviceId, setAudioDeviceId] = React.useState<string>(
|
|
||||||
initialUserChoices.audioDeviceId
|
initialUserChoices.audioDeviceId
|
||||||
)
|
)
|
||||||
const [videoDeviceId, setVideoDeviceId] = React.useState<string>(
|
const [videoDeviceId, setVideoDeviceId] = useState<string>(
|
||||||
initialUserChoices.videoDeviceId
|
initialUserChoices.videoDeviceId
|
||||||
)
|
)
|
||||||
const [username, setUsername] = React.useState(initialUserChoices.username)
|
const [username, setUsername] = useState<string>(initialUserChoices.username)
|
||||||
|
|
||||||
// Save user choices to persistent storage.
|
useEffect(() => {
|
||||||
React.useEffect(() => {
|
|
||||||
saveAudioInputEnabled(audioEnabled)
|
|
||||||
}, [audioEnabled, saveAudioInputEnabled])
|
|
||||||
React.useEffect(() => {
|
|
||||||
saveVideoInputEnabled(videoEnabled)
|
|
||||||
}, [videoEnabled, saveVideoInputEnabled])
|
|
||||||
React.useEffect(() => {
|
|
||||||
saveAudioInputDeviceId(audioDeviceId)
|
saveAudioInputDeviceId(audioDeviceId)
|
||||||
}, [audioDeviceId, saveAudioInputDeviceId])
|
}, [audioDeviceId, saveAudioInputDeviceId])
|
||||||
React.useEffect(() => {
|
|
||||||
|
useEffect(() => {
|
||||||
saveVideoInputDeviceId(videoDeviceId)
|
saveVideoInputDeviceId(videoDeviceId)
|
||||||
}, [videoDeviceId, saveVideoInputDeviceId])
|
}, [videoDeviceId, saveVideoInputDeviceId])
|
||||||
React.useEffect(() => {
|
|
||||||
|
useEffect(() => {
|
||||||
saveUsername(username)
|
saveUsername(username)
|
||||||
}, [username, saveUsername])
|
}, [username, saveUsername])
|
||||||
|
|
||||||
|
const [audioEnabled, setAudioEnabled] = useState(true)
|
||||||
|
const [videoEnabled, setVideoEnabled] = useState(true)
|
||||||
|
|
||||||
const tracks = usePreviewTracks(
|
const tracks = usePreviewTracks(
|
||||||
{
|
{
|
||||||
audio: audioEnabled
|
audio: { deviceId: initialUserChoices.audioDeviceId },
|
||||||
? { deviceId: initialUserChoices.audioDeviceId }
|
video: { deviceId: initialUserChoices.videoDeviceId },
|
||||||
: false,
|
|
||||||
video: videoEnabled
|
|
||||||
? { deviceId: initialUserChoices.videoDeviceId }
|
|
||||||
: false,
|
|
||||||
},
|
},
|
||||||
onError
|
onError
|
||||||
)
|
)
|
||||||
|
|
||||||
const videoEl = React.useRef(null)
|
const videoTrack = useMemo(
|
||||||
|
|
||||||
const videoTrack = React.useMemo(
|
|
||||||
() =>
|
() =>
|
||||||
tracks?.filter(
|
tracks?.filter(
|
||||||
(track) => track.kind === Track.Kind.Video
|
(track) => track.kind === Track.Kind.Video
|
||||||
@@ -121,7 +69,7 @@ export const Join = ({
|
|||||||
[tracks]
|
[tracks]
|
||||||
)
|
)
|
||||||
|
|
||||||
const audioTrack = React.useMemo(
|
const audioTrack = useMemo(
|
||||||
() =>
|
() =>
|
||||||
tracks?.filter(
|
tracks?.filter(
|
||||||
(track) => track.kind === Track.Kind.Audio
|
(track) => track.kind === Track.Kind.Audio
|
||||||
@@ -129,59 +77,37 @@ export const Join = ({
|
|||||||
[tracks]
|
[tracks]
|
||||||
)
|
)
|
||||||
|
|
||||||
const facingMode = React.useMemo(() => {
|
const videoEl = useRef(null)
|
||||||
if (videoTrack) {
|
|
||||||
const { facingMode } = facingModeFromLocalTrack(videoTrack)
|
|
||||||
return facingMode
|
|
||||||
} else {
|
|
||||||
return 'undefined'
|
|
||||||
}
|
|
||||||
}, [videoTrack])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoEl.current && videoTrack) {
|
const videoElement = videoEl.current as HTMLVideoElement | null
|
||||||
|
|
||||||
|
const handleVideoLoaded = () => {
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.style.opacity = '1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoElement && videoTrack && videoEnabled) {
|
||||||
videoTrack.unmute()
|
videoTrack.unmute()
|
||||||
videoTrack.attach(videoEl.current)
|
videoTrack.attach(videoElement)
|
||||||
|
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
videoTrack?.detach()
|
videoTrack?.detach()
|
||||||
|
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
|
||||||
}
|
}
|
||||||
}, [videoTrack])
|
}, [videoTrack, videoEnabled])
|
||||||
|
|
||||||
const [isValid, setIsValid] = React.useState<boolean>()
|
|
||||||
|
|
||||||
const handleValidation = React.useCallback((values: LocalUserChoices) => {
|
|
||||||
return values.username !== ''
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const newUserChoices = {
|
|
||||||
username,
|
|
||||||
videoEnabled,
|
|
||||||
videoDeviceId,
|
|
||||||
audioEnabled,
|
|
||||||
audioDeviceId,
|
|
||||||
}
|
|
||||||
setUserChoices(newUserChoices)
|
|
||||||
setIsValid(handleValidation(newUserChoices))
|
|
||||||
}, [
|
|
||||||
username,
|
|
||||||
videoEnabled,
|
|
||||||
handleValidation,
|
|
||||||
audioEnabled,
|
|
||||||
audioDeviceId,
|
|
||||||
videoDeviceId,
|
|
||||||
])
|
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (handleValidation(userChoices)) {
|
onSubmit({
|
||||||
if (typeof onSubmit === 'function') {
|
audioEnabled,
|
||||||
onSubmit(userChoices)
|
videoEnabled,
|
||||||
}
|
audioDeviceId,
|
||||||
} else {
|
videoDeviceId,
|
||||||
log.warn('Validation failed with: ', userChoices)
|
username,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -232,67 +158,60 @@ export const Join = ({
|
|||||||
height: 'auto',
|
height: 'auto',
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
'--tw-shadow':
|
'--tw-shadow':
|
||||||
'0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a',
|
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
|
||||||
'--tw-shadow-colored':
|
'--tw-shadow-colored':
|
||||||
'0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
|
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
|
||||||
boxShadow:
|
boxShadow:
|
||||||
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
|
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
|
||||||
|
backgroundColor: 'black',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{videoTrack && (
|
{videoTrack && videoEnabled ? (
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
<video
|
<video
|
||||||
ref={videoEl}
|
ref={videoEl}
|
||||||
width="1280"
|
width="1280"
|
||||||
height="720"
|
height="720"
|
||||||
data-lk-facing-mode={facingMode}
|
|
||||||
className={css({
|
className={css({
|
||||||
display: 'block',
|
display: 'block',
|
||||||
width: '100%',
|
width: '102%',
|
||||||
height: '100%',
|
height: '102%',
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
transform: 'rotateY(180deg)',
|
transform: 'rotateY(180deg)',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
{(!videoTrack || !videoEnabled) && (
|
|
||||||
<div
|
<div
|
||||||
id="container"
|
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
width: '100%',
|
||||||
top: 0,
|
height: '100%',
|
||||||
left: 0,
|
color: 'white',
|
||||||
right: 0,
|
display: 'flex',
|
||||||
bottom: 0,
|
justifyContent: 'center',
|
||||||
backgroundColor: '#000',
|
alignItems: 'center',
|
||||||
display: 'grid',
|
|
||||||
placeItems: 'center',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ParticipantPlaceholder
|
<p
|
||||||
className={css({
|
className={css({
|
||||||
maxWidth: '100%',
|
fontSize: '24px',
|
||||||
height: '70%',
|
fontWeight: '300',
|
||||||
})}
|
})}
|
||||||
/>
|
>
|
||||||
|
{!videoEnabled && t('cameraDisabled')}
|
||||||
|
{videoEnabled && !videoTrack && t('cameraStarting')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="lk-button-group-container"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className={css({
|
<HStack justify="center" padding={1.5}>
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '1.5rem',
|
|
||||||
gap: '1rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<SelectToggleDevice
|
<SelectToggleDevice
|
||||||
source={Track.Source.Microphone}
|
source={Track.Source.Microphone}
|
||||||
initialState={audioEnabled}
|
initialState={audioEnabled}
|
||||||
track={audioTrack}
|
track={audioTrack}
|
||||||
initialDeviceId={audioDeviceId}
|
initialDeviceId={initialUserChoices.audioDeviceId}
|
||||||
onChange={(enabled) => setAudioEnabled(enabled)}
|
onChange={(enabled) => setAudioEnabled(enabled)}
|
||||||
onDeviceError={(error) => console.error(error)}
|
onDeviceError={(error) => console.error(error)}
|
||||||
onActiveDeviceChange={(deviceId) =>
|
onActiveDeviceChange={(deviceId) =>
|
||||||
@@ -304,19 +223,16 @@ export const Join = ({
|
|||||||
source={Track.Source.Camera}
|
source={Track.Source.Camera}
|
||||||
initialState={videoEnabled}
|
initialState={videoEnabled}
|
||||||
track={videoTrack}
|
track={videoTrack}
|
||||||
initialDeviceId={videoDeviceId}
|
initialDeviceId={initialUserChoices.videoDeviceId}
|
||||||
onChange={(enabled) => {
|
onChange={(enabled) => setVideoEnabled(enabled)}
|
||||||
setVideoEnabled(enabled)
|
|
||||||
}}
|
|
||||||
onDeviceError={(error) => console.error(error)}
|
onDeviceError={(error) => console.error(error)}
|
||||||
onActiveDeviceChange={(deviceId) =>
|
onActiveDeviceChange={(deviceId) =>
|
||||||
setVideoDeviceId(deviceId ?? '')
|
setVideoDeviceId(deviceId ?? '')
|
||||||
}
|
}
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -328,48 +244,35 @@ export const Join = ({
|
|||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<form
|
<Form
|
||||||
className={css({
|
onSubmit={handleSubmit}
|
||||||
display: 'flex',
|
submitLabel={t('joinLabel')}
|
||||||
flexDirection: 'column',
|
submitButtonProps={{
|
||||||
justifyContent: 'center',
|
fullWidth: true,
|
||||||
alignItems: 'center',
|
}}
|
||||||
gap: '1rem',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<H lvl={1} className={css({ marginBottom: 0 })}>
|
<VStack marginBottom={1}>
|
||||||
{t('join.heading')}
|
<H lvl={1} margin={false}>
|
||||||
</H>
|
{t('heading')}
|
||||||
<Field
|
</H>
|
||||||
type="text"
|
<Field
|
||||||
label={userLabel}
|
type="text"
|
||||||
defaultValue={username}
|
onChange={setUsername}
|
||||||
onChange={(value) => setUsername(value)}
|
label={t('usernameLabel')}
|
||||||
validate={(value) => {
|
aria-label={t('usernameLabel')}
|
||||||
return !value ? <p>{t('join.errors.usernameEmpty')}</p> : null
|
defaultValue={initialUserChoices?.username}
|
||||||
}}
|
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||||
className={css({
|
wrapperProps={{
|
||||||
width: '100%',
|
noMargin: true,
|
||||||
})}
|
fullWidth: true,
|
||||||
wrapperProps={{
|
}}
|
||||||
noMargin: true,
|
labelProps={{
|
||||||
fullWidth: true,
|
center: true,
|
||||||
}}
|
}}
|
||||||
labelProps={{
|
maxLength={50}
|
||||||
center: true,
|
/>
|
||||||
}}
|
</VStack>
|
||||||
maxLength={50}
|
</Form>
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant={'primary'}
|
|
||||||
onPress={handleSubmit}
|
|
||||||
isDisabled={!isValid}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{joinLabel}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
"usernameLabel": "",
|
"usernameLabel": "",
|
||||||
"errors": {
|
"errors": {
|
||||||
"usernameEmpty": ""
|
"usernameEmpty": ""
|
||||||
}
|
},
|
||||||
|
"cameraDisabled": "",
|
||||||
|
"cameraStarting": ""
|
||||||
},
|
},
|
||||||
"leaveRoomPrompt": "",
|
"leaveRoomPrompt": "",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
"usernameLabel": "Your name",
|
"usernameLabel": "Your name",
|
||||||
"errors": {
|
"errors": {
|
||||||
"usernameEmpty": "Your name cannot be empty"
|
"usernameEmpty": "Your name cannot be empty"
|
||||||
}
|
},
|
||||||
|
"cameraDisabled": "Camera is disabled.",
|
||||||
|
"cameraStarting": "Camera is starting."
|
||||||
},
|
},
|
||||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
"usernameLabel": "Votre nom",
|
"usernameLabel": "Votre nom",
|
||||||
"errors": {
|
"errors": {
|
||||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||||
}
|
},
|
||||||
|
"cameraDisabled": "La caméra est désactivée.",
|
||||||
|
"cameraStarting": "La caméra va démarrer."
|
||||||
},
|
},
|
||||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Form as RACForm, type FormProps } from 'react-aria-components'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { Button, useCloseDialog } from '@/primitives'
|
import { Button, useCloseDialog } from '@/primitives'
|
||||||
|
import { ButtonProps } from '@/primitives/Button'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* From wrapper that exposes form data on submit and adds submit/cancel buttons
|
* From wrapper that exposes form data on submit and adds submit/cancel buttons
|
||||||
@@ -13,6 +14,7 @@ import { Button, useCloseDialog } from '@/primitives'
|
|||||||
export const Form = ({
|
export const Form = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
|
submitButtonProps,
|
||||||
withCancelButton = true,
|
withCancelButton = true,
|
||||||
onCancelButtonPress,
|
onCancelButtonPress,
|
||||||
children,
|
children,
|
||||||
@@ -25,6 +27,7 @@ export const Form = ({
|
|||||||
event: FormEvent<HTMLFormElement>
|
event: FormEvent<HTMLFormElement>
|
||||||
) => void
|
) => void
|
||||||
submitLabel: string
|
submitLabel: string
|
||||||
|
submitButtonProps?: ButtonProps
|
||||||
withCancelButton?: boolean
|
withCancelButton?: boolean
|
||||||
onCancelButtonPress?: () => void
|
onCancelButtonPress?: () => void
|
||||||
}) => {
|
}) => {
|
||||||
@@ -47,7 +50,7 @@ export const Form = ({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<HStack gap="gutter">
|
<HStack gap="gutter">
|
||||||
<Button type="submit" variant="primary">
|
<Button type="submit" variant="primary" {...submitButtonProps}>
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{!!onCancel && (
|
{!!onCancel && (
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Text } from './Text'
|
import { Text, TextProps } from './Text'
|
||||||
|
|
||||||
export const H = ({
|
export const H = ({
|
||||||
children,
|
children,
|
||||||
lvl,
|
lvl,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLHeadingElement> & { lvl: 1 | 2 | 3 }) => {
|
}: React.HTMLAttributes<HTMLHeadingElement> & {
|
||||||
|
lvl: 1 | 2 | 3
|
||||||
|
} & TextProps) => {
|
||||||
const tag = `h${lvl}` as const
|
const tag = `h${lvl}` as const
|
||||||
return (
|
return (
|
||||||
<Text as={tag} variant={tag} {...props}>
|
<Text as={tag} variant={tag} {...props}>
|
||||||
|
|||||||
Reference in New Issue
Block a user