Compare commits
9 Commits
main
...
homemade-join
| Author | SHA1 | Date | |
|---|---|---|---|
| acb024b64a | |||
| 31dcbddcf3 | |||
| d6ca3ed202 | |||
| 249011156e | |||
| dbbb4b356e | |||
| 4714067a51 | |||
| 6cc13fa39a | |||
| 1c93f04b1f | |||
| 71811155b3 |
@@ -199,6 +199,7 @@ const config: Config = {
|
||||
text: { value: '{colors.white}' },
|
||||
subtle: { value: '{colors.red.100}' },
|
||||
'subtle-text': { value: '{colors.red.700}' },
|
||||
...pandaPreset.theme.tokens.colors.red,
|
||||
},
|
||||
success: {
|
||||
DEFAULT: { value: '{colors.green.700}' },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from 'hoofd'
|
||||
import { Switch, Route } from 'wouter'
|
||||
import { I18nProvider } from 'react-aria-components'
|
||||
import { Layout } from './layout/Layout'
|
||||
import { NotFoundScreen } from './components/NotFoundScreen'
|
||||
import { routes } from './routes'
|
||||
@@ -23,15 +24,17 @@ function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback={null}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useAudioOutputs } from './utils/useAudioOutputs'
|
||||
export { usePersistedMediaDeviceSelect } from './utils/usePersistedMediaDeviceSelect'
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const getOutputDevices = () => {
|
||||
return navigator.mediaDevices
|
||||
.getUserMedia({ audio: true, video: false })
|
||||
.then(() => navigator.mediaDevices.enumerateDevices())
|
||||
.then((devices) => devices.filter(({ kind }) => kind === 'audiooutput'))
|
||||
}
|
||||
|
||||
/**
|
||||
* custom hook to fetch audio outputs
|
||||
*
|
||||
* this is used instead of livekit's useMediaDevices because the livekit integrated one seems to request
|
||||
* outputs in a weird order, resulting in empty results in firefox if we didn't ask for input before
|
||||
*/
|
||||
export const useAudioOutputs = () => {
|
||||
const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([])
|
||||
useEffect(() => {
|
||||
const retrieveOutputDevices = () => {
|
||||
getOutputDevices()
|
||||
.then(setAudioOutputs)
|
||||
.catch((error) => {
|
||||
console.error('Audio outputs retrieval error :', error)
|
||||
})
|
||||
}
|
||||
retrieveOutputDevices()
|
||||
const onDeviceChange = () => {
|
||||
retrieveOutputDevices()
|
||||
}
|
||||
navigator?.mediaDevices?.addEventListener('devicechange', onDeviceChange)
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener('devicechange', onDeviceChange)
|
||||
}
|
||||
}, [])
|
||||
return audioOutputs
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||
import { settingsStore } from '@/features/settings'
|
||||
|
||||
/**
|
||||
* wrap livekit's useMediaDeviceSelect to automatically save in our devices state user selection
|
||||
*
|
||||
* note: audiooutput devices are not handled here as we dont use useMediaDeviceSelect for them
|
||||
*/
|
||||
export const usePersistedMediaDeviceSelect = (
|
||||
...args: Parameters<typeof useMediaDeviceSelect>
|
||||
): ReturnType<typeof useMediaDeviceSelect> => {
|
||||
const results = useMediaDeviceSelect(...args)
|
||||
const originalSetter = results.setActiveMediaDevice
|
||||
results.setActiveMediaDevice = (
|
||||
...activeMediaDeviceArgs: Parameters<typeof results.setActiveMediaDevice>
|
||||
) => {
|
||||
const id = activeMediaDeviceArgs[0]
|
||||
if (args[0].kind === 'audioinput') {
|
||||
settingsStore.devices.micDeviceId = id
|
||||
}
|
||||
if (args[0].kind === 'videoinput') {
|
||||
settingsStore.devices.cameraDeviceId = id
|
||||
}
|
||||
return originalSetter(...activeMediaDeviceArgs)
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
formatChatMessageLinks,
|
||||
LiveKitRoom,
|
||||
VideoConference,
|
||||
type LocalUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import { Room, RoomOptions } from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
@@ -18,6 +17,7 @@ import { fetchRoom } from '../api/fetchRoom'
|
||||
import { ApiRoom } from '../api/ApiRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
import { type SettingsState } from '@/features/settings'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -26,7 +26,10 @@ export const Conference = ({
|
||||
mode = 'join',
|
||||
}: {
|
||||
roomId: string
|
||||
userConfig: LocalUserChoices
|
||||
userConfig: {
|
||||
devices: SettingsState['devices']
|
||||
username: SettingsState['username']
|
||||
}
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
}) => {
|
||||
@@ -65,14 +68,21 @@ export const Conference = ({
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
videoCaptureDefaults: {
|
||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||
deviceId: userConfig.devices.cameraDeviceId ?? undefined,
|
||||
},
|
||||
audioCaptureDefaults: {
|
||||
deviceId: userConfig.audioDeviceId ?? undefined,
|
||||
deviceId: userConfig.devices.micDeviceId ?? undefined,
|
||||
},
|
||||
audioOutput: {
|
||||
deviceId: userConfig.devices.speakerDeviceId ?? undefined,
|
||||
},
|
||||
}
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
||||
}, [
|
||||
userConfig.devices.cameraDeviceId,
|
||||
userConfig.devices.micDeviceId,
|
||||
userConfig.devices.speakerDeviceId,
|
||||
])
|
||||
|
||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||
|
||||
@@ -116,8 +126,8 @@ export const Conference = ({
|
||||
serverUrl={data?.livekit?.url}
|
||||
token={data?.livekit?.token}
|
||||
connect={true}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={userConfig.videoEnabled}
|
||||
audio={userConfig.devices.enableMic}
|
||||
video={userConfig.devices.enableCamera}
|
||||
>
|
||||
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
|
||||
{showInviteDialog && (
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import {
|
||||
Button,
|
||||
Div,
|
||||
Field,
|
||||
Form,
|
||||
H,
|
||||
Menu,
|
||||
MenuList,
|
||||
ToggleButton,
|
||||
VerticallyOffCenter,
|
||||
} from '@/primitives'
|
||||
import { Center, HStack, VStack } from '@/styled-system/jsx'
|
||||
import {
|
||||
RiArrowDropDownLine,
|
||||
RiMicLine,
|
||||
RiMicOffLine,
|
||||
RiVideoOffLine,
|
||||
RiVideoOnLine,
|
||||
RiVolumeUpLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useMaybeRoomContext,
|
||||
usePreviewTracks,
|
||||
} from '@livekit/components-react'
|
||||
import { Track, LocalVideoTrack, LocalAudioTrack } from 'livekit-client'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
usePersistedMediaDeviceSelect,
|
||||
useAudioOutputs,
|
||||
} from '@/features/devices'
|
||||
import { settingsStore, type SettingsState } from '@/features/settings'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
export const HomemadeJoin = ({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (choices: {
|
||||
devices: SettingsState['devices']
|
||||
username: SettingsState['username']
|
||||
}) => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const settingsSnap = useSnapshot(settingsStore)
|
||||
const [initialUserChoices] = useState({ ...settingsSnap.devices })
|
||||
|
||||
const tracks = usePreviewTracks({
|
||||
audio: settingsSnap.devices.enableMic
|
||||
? { deviceId: initialUserChoices.micDeviceId }
|
||||
: false,
|
||||
video: settingsSnap.devices.enableCamera
|
||||
? { deviceId: initialUserChoices.cameraDeviceId }
|
||||
: false,
|
||||
})
|
||||
|
||||
const videoEl = useRef(null)
|
||||
const videoTrack = useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Video
|
||||
)[0] as LocalVideoTrack,
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const audioTrack = useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Audio
|
||||
)[0] as LocalAudioTrack,
|
||||
[tracks]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (videoEl.current && videoTrack) {
|
||||
videoTrack.unmute()
|
||||
videoTrack.attach(videoEl.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
videoTrack?.detach()
|
||||
}
|
||||
}, [videoTrack])
|
||||
const room = useMaybeRoomContext()
|
||||
|
||||
const {
|
||||
devices: micDevices,
|
||||
activeDeviceId: activeMicDeviceId,
|
||||
setActiveMediaDevice: setActiveMicDevice,
|
||||
} = usePersistedMediaDeviceSelect({
|
||||
kind: 'audioinput',
|
||||
room,
|
||||
track: audioTrack,
|
||||
requestPermissions: true,
|
||||
})
|
||||
const {
|
||||
devices: cameraDevices,
|
||||
activeDeviceId: activeCameraDeviceId,
|
||||
setActiveMediaDevice: setActiveCameraDevice,
|
||||
} = usePersistedMediaDeviceSelect({
|
||||
kind: 'videoinput',
|
||||
room,
|
||||
track: videoTrack,
|
||||
requestPermissions: true,
|
||||
})
|
||||
const speakerDevices = useAudioOutputs()
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsStore.devices.micDeviceId) {
|
||||
setActiveMicDevice(settingsStore.devices.micDeviceId)
|
||||
}
|
||||
if (settingsStore.devices.cameraDeviceId) {
|
||||
setActiveCameraDevice(settingsStore.devices.cameraDeviceId)
|
||||
}
|
||||
}, [setActiveCameraDevice, setActiveMicDevice])
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<VerticallyOffCenter>
|
||||
<Div
|
||||
className={css({
|
||||
margin: 'auto',
|
||||
flexWrap: 'wrap',
|
||||
width: 'fit-content',
|
||||
maxWidth: 'full',
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
paddingX: 1,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
lg: {
|
||||
alignItems: 'stretch',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<VStack
|
||||
className={css({
|
||||
width: 'full',
|
||||
maxWidth: '38rem',
|
||||
margin: '0 auto',
|
||||
alignItems: 'center',
|
||||
flexShrink: '1',
|
||||
})}
|
||||
>
|
||||
<Center
|
||||
className={css({
|
||||
width: '38rem',
|
||||
maxWidth: 'full',
|
||||
height: 'auto',
|
||||
aspectRatio: '16 / 9',
|
||||
background: 'gray.900',
|
||||
color: 'white',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{videoTrack && settingsSnap.devices.enableCamera ? (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
ref={videoEl}
|
||||
width="608"
|
||||
height="342"
|
||||
className={css({
|
||||
width: 'full',
|
||||
height: 'auto',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
settingsSnap.devices.enableCamera === false && (
|
||||
<p>{t('cameraPlaceholder')}</p>
|
||||
)
|
||||
)}
|
||||
</Center>
|
||||
<HStack gap={1} justify="center" flexWrap={'wrap'}>
|
||||
{/* audio output dropdown */}
|
||||
<Menu>
|
||||
<Button
|
||||
tooltip={t('chooseSpeaker')}
|
||||
aria-label={t('chooseSpeaker')}
|
||||
>
|
||||
<RiVolumeUpLine />
|
||||
<RiArrowDropDownLine />
|
||||
</Button>
|
||||
<MenuList
|
||||
items={speakerDevices.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))}
|
||||
selectedItem={settingsSnap.devices.speakerDeviceId}
|
||||
onAction={(value) => {
|
||||
settingsStore.devices.speakerDeviceId = value as string
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
|
||||
{/* audio input toggle + dropdown */}
|
||||
<HStack gap={0}>
|
||||
<ToggleButton
|
||||
isSelected={settingsSnap.devices.enableMic}
|
||||
variant={
|
||||
settingsSnap.devices.enableMic ? undefined : 'danger'
|
||||
}
|
||||
toggledStyles={false}
|
||||
onChange={(enabled) =>
|
||||
(settingsStore.devices.enableMic = enabled)
|
||||
}
|
||||
aria-label={
|
||||
settingsSnap.devices.enableMic
|
||||
? `${t('micIsOn')} ${t('toggleOff')}`
|
||||
: `${t('micIsOff')} ${t('toggleOn')}`
|
||||
}
|
||||
tooltip={
|
||||
settingsSnap.devices.enableMic ? (
|
||||
<>
|
||||
{t('micIsOn')}
|
||||
<br />
|
||||
{t('toggleOff')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('micIsOff')}
|
||||
<br />
|
||||
{t('toggleOn')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
groupPosition="left"
|
||||
>
|
||||
{settingsSnap.devices.enableMic ? (
|
||||
<RiMicLine />
|
||||
) : (
|
||||
<RiMicOffLine />
|
||||
)}
|
||||
</ToggleButton>
|
||||
<Menu>
|
||||
<Button
|
||||
tooltip={t('chooseMic')}
|
||||
aria-label={t('chooseMic')}
|
||||
groupPosition="right"
|
||||
square
|
||||
>
|
||||
<RiArrowDropDownLine />
|
||||
</Button>
|
||||
<MenuList
|
||||
items={micDevices.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))}
|
||||
selectedItem={activeMicDeviceId}
|
||||
onAction={(value) => {
|
||||
setActiveMicDevice(value as string)
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</HStack>
|
||||
|
||||
{/* video toggle + dropdown */}
|
||||
<HStack gap={0}>
|
||||
<ToggleButton
|
||||
isSelected={settingsSnap.devices.enableCamera}
|
||||
variant={
|
||||
settingsSnap.devices.enableCamera ? undefined : 'danger'
|
||||
}
|
||||
toggledStyles={false}
|
||||
onChange={(enabled) =>
|
||||
(settingsStore.devices.enableCamera = enabled)
|
||||
}
|
||||
aria-label={
|
||||
settingsSnap.devices.enableMic
|
||||
? `${t('cameraIsOn')} ${t('toggleOff')}`
|
||||
: `${t('cameraIsOff')} ${t('toggleOn')}`
|
||||
}
|
||||
tooltip={
|
||||
settingsSnap.devices.enableMic ? (
|
||||
<>
|
||||
{t('cameraIsOn')}
|
||||
<br />
|
||||
{t('toggleOff')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('cameraIsOff')}
|
||||
<br />
|
||||
{t('toggleOn')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
groupPosition="left"
|
||||
>
|
||||
{settingsSnap.devices.enableCamera ? (
|
||||
<RiVideoOnLine />
|
||||
) : (
|
||||
<RiVideoOffLine />
|
||||
)}
|
||||
</ToggleButton>
|
||||
<Menu>
|
||||
<Button
|
||||
tooltip={t('chooseCamera')}
|
||||
aria-label={t('chooseCamera')}
|
||||
groupPosition="right"
|
||||
square
|
||||
>
|
||||
<RiArrowDropDownLine />
|
||||
</Button>
|
||||
<MenuList
|
||||
items={cameraDevices.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))}
|
||||
selectedItem={activeCameraDeviceId}
|
||||
onAction={(value) => {
|
||||
setActiveCameraDevice(value as string)
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Div width="24rem" maxWidth="full" flexShrink="1">
|
||||
<VerticallyOffCenter>
|
||||
<Center>
|
||||
<H lvl={1}>{t('heading')}</H>
|
||||
</Center>
|
||||
<Form
|
||||
onSubmit={(data) => {
|
||||
settingsStore.username = (data.username as string).trim()
|
||||
onSubmit({
|
||||
devices: { ...settingsStore.devices },
|
||||
username: settingsStore.username,
|
||||
})
|
||||
}}
|
||||
submitLabel={t('joinMeeting')}
|
||||
withSubmitButton={false}
|
||||
>
|
||||
<Field
|
||||
type="text"
|
||||
name="username"
|
||||
defaultValue={settingsSnap.username}
|
||||
label={t('usernameLabel')}
|
||||
description={t('usernameHint')}
|
||||
isRequired
|
||||
/>
|
||||
<Center>
|
||||
<Button type="submit" variant="primary">
|
||||
{t('joinMeeting')}
|
||||
</Button>
|
||||
</Center>
|
||||
</Form>
|
||||
</VerticallyOffCenter>
|
||||
</Div>
|
||||
</Div>
|
||||
</VerticallyOffCenter>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
usePersistentUserChoices,
|
||||
type LocalUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import { useParams } from 'wouter'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
import { Conference } from '../components/Conference'
|
||||
import { Join } from '../components/Join'
|
||||
import { HomemadeJoin } from '../components/HomemadeJoin'
|
||||
import { settingsStore, type SettingsState } from '@/features/settings'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
export const Room = () => {
|
||||
const { isLoggedIn } = useUser()
|
||||
const { userChoices: existingUserChoices } = usePersistentUserChoices()
|
||||
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
|
||||
const settingsSnap = useSnapshot(settingsStore)
|
||||
const existingUserConfig = {
|
||||
username: settingsSnap.username,
|
||||
devices: settingsSnap.devices,
|
||||
}
|
||||
const [userConfig, setUserConfig] = useState<null | {
|
||||
username: SettingsState['username']
|
||||
devices: SettingsState['devices']
|
||||
}>(null)
|
||||
|
||||
const { roomId } = useParams()
|
||||
const initialRoomData = history.state?.initialRoomData
|
||||
@@ -26,7 +31,7 @@ export const Room = () => {
|
||||
if (!userConfig && !skipJoinScreen) {
|
||||
return (
|
||||
<UserAware>
|
||||
<Join onSubmit={setUserConfig} />
|
||||
<HomemadeJoin onSubmit={setUserConfig} />
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
@@ -38,7 +43,7 @@ export const Room = () => {
|
||||
roomId={roomId}
|
||||
mode={mode}
|
||||
userConfig={{
|
||||
...existingUserChoices,
|
||||
...existingUserConfig,
|
||||
...userConfig,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { SettingsButton } from './components/SettingsButton'
|
||||
export { SettingsDialog } from './components/SettingsDialog'
|
||||
export { settingsStore } from './stores/settings'
|
||||
export { type SettingsState } from './stores/settings'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
import { devtools } from 'valtio/utils'
|
||||
|
||||
export type SettingsState = {
|
||||
username: string | undefined
|
||||
devices: {
|
||||
/**
|
||||
* MediaDeviceInfo id
|
||||
*/
|
||||
speakerDeviceId: string | undefined
|
||||
/**
|
||||
* MediaDeviceInfo id
|
||||
*/
|
||||
micDeviceId: string | undefined
|
||||
/**
|
||||
* MediaDeviceInfo id
|
||||
*/
|
||||
cameraDeviceId: string | undefined
|
||||
enableMic: boolean
|
||||
enableCamera: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// sync the valtio store with localstorage data
|
||||
// @TODO: make it easier to have "persisted" stores as we will definitely use it quite often
|
||||
|
||||
const localData = localStorage.getItem('meet.settings')
|
||||
|
||||
export const settingsStore = proxy<SettingsState>(
|
||||
localData
|
||||
? JSON.parse(localData)
|
||||
: {
|
||||
username: undefined,
|
||||
devices: {
|
||||
speakerDeviceId: undefined,
|
||||
micDeviceId: undefined,
|
||||
cameraDeviceId: undefined,
|
||||
enableMic: false,
|
||||
enableCamera: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
subscribe(settingsStore, () => {
|
||||
localStorage.setItem('meet.settings', JSON.stringify(settingsStore))
|
||||
})
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
devtools(settingsStore, { name: 'settings', enabled: true })
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Popover, PopoverList } from '@/primitives'
|
||||
import { useLanguageLabels } from './useLanguageLabels'
|
||||
|
||||
export const LanguageSelector = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||
return (
|
||||
<Popover aria-label={t('languageSelector.popoverLabel')}>
|
||||
<Button
|
||||
aria-label={t('languageSelector.buttonLabel', {
|
||||
currentLanguage: currentLanguage.label,
|
||||
})}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
outline
|
||||
>
|
||||
{i18n.language}
|
||||
</Button>
|
||||
<PopoverList
|
||||
items={languagesList}
|
||||
onAction={(lang) => {
|
||||
i18n.changeLanguage(lang)
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Div, VerticallyOffCenter } from '@/primitives'
|
||||
import type { SystemStyleObject } from '../styled-system/types'
|
||||
import type { SystemStyleObject } from '@/styled-system/types'
|
||||
|
||||
export const Centered = ({
|
||||
width = '38rem',
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Link } from 'wouter'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Stack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { A, Button, Popover, PopoverList, Text } from '@/primitives'
|
||||
import { A, Text, Button } from '@/primitives'
|
||||
import { SettingsButton } from '@/features/settings'
|
||||
import { authUrl, logoutUrl, useUser } from '@/features/auth'
|
||||
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
||||
import { Feedback } from '@/components/Feedback'
|
||||
import { Menu } from '@/primitives/Menu'
|
||||
import { MenuList } from '@/primitives/MenuList'
|
||||
|
||||
export const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -64,7 +66,7 @@ export const Header = () => {
|
||||
<A href={authUrl()}>{t('login')}</A>
|
||||
)}
|
||||
{!!user && (
|
||||
<Popover aria-label={t('logout')}>
|
||||
<Menu>
|
||||
<Button
|
||||
size="sm"
|
||||
invisible
|
||||
@@ -73,17 +75,15 @@ export const Header = () => {
|
||||
>
|
||||
{user.email}
|
||||
</Button>
|
||||
<PopoverList
|
||||
items={[
|
||||
{ key: 'logout', value: 'logout', label: t('logout') },
|
||||
]}
|
||||
<MenuList
|
||||
items={[{ value: 'logout', label: t('logout') }]}
|
||||
onAction={(value) => {
|
||||
if (value === 'logout') {
|
||||
window.location.href = logoutUrl()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</Menu>
|
||||
)}
|
||||
<SettingsButton />
|
||||
</Stack>
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
"heading": ""
|
||||
},
|
||||
"feedbackAlert": "",
|
||||
"forbidden": {
|
||||
"heading": ""
|
||||
},
|
||||
"languageSelector": {
|
||||
"buttonLabel": "",
|
||||
"popoverLabel": ""
|
||||
},
|
||||
"loading": "",
|
||||
"loggedInUserTooltip": "",
|
||||
"login": "Anmelden",
|
||||
|
||||
@@ -4,11 +4,24 @@
|
||||
"heading": ""
|
||||
},
|
||||
"join": {
|
||||
"cameraIsOff": "",
|
||||
"cameraIsOn": "",
|
||||
"cameraPlaceholder": "",
|
||||
"camlabel": "",
|
||||
"chooseCamera": "",
|
||||
"chooseMic": "",
|
||||
"chooseSpeaker": "",
|
||||
"heading": "",
|
||||
"joinLabel": "",
|
||||
"joinMeeting": "",
|
||||
"micIsOff": "",
|
||||
"micIsOn": "",
|
||||
"micLabel": "",
|
||||
"userLabel": ""
|
||||
"toggleOff": "",
|
||||
"toggleOn": "",
|
||||
"userLabel": "",
|
||||
"usernameHint": "",
|
||||
"usernameLabel": ""
|
||||
},
|
||||
"leaveRoomPrompt": "",
|
||||
"shareDialog": {
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
"heading": "An error occured while loading the page"
|
||||
},
|
||||
"feedbackAlert": "Give us feedback",
|
||||
"forbidden": {
|
||||
"heading": "You don't have the permission to view this page"
|
||||
},
|
||||
"languageSelector": {
|
||||
"buttonLabel": "Change language (currently {{currentLanguage}})",
|
||||
"popoverLabel": "Choose language"
|
||||
},
|
||||
"loading": "Loading…",
|
||||
"loggedInUserTooltip": "Logged in as…",
|
||||
"login": "Login",
|
||||
|
||||
@@ -4,11 +4,24 @@
|
||||
"heading": "Help us improve Meet"
|
||||
},
|
||||
"join": {
|
||||
"camlabel": "Camera",
|
||||
"heading": "Join the meeting",
|
||||
"joinLabel": "Join",
|
||||
"micLabel": "Microphone",
|
||||
"userLabel": "Your name"
|
||||
"cameraIsOff": "Camera is off.",
|
||||
"cameraIsOn": "Camera is on.",
|
||||
"cameraPlaceholder": "Turn on the camera to see the preview",
|
||||
"camlabel": "",
|
||||
"chooseCamera": "Select camera",
|
||||
"chooseMic": "Select microphone",
|
||||
"chooseSpeaker": "Select speakers",
|
||||
"heading": "Verify your settings",
|
||||
"joinLabel": "",
|
||||
"joinMeeting": "Join meeting",
|
||||
"micIsOff": "Microphone is off.",
|
||||
"micIsOn": "Microphone is on.",
|
||||
"micLabel": "",
|
||||
"toggleOff": "Click to turn off",
|
||||
"toggleOn": "Click to turn on",
|
||||
"userLabel": "",
|
||||
"usernameHint": "Shown to other participants",
|
||||
"usernameLabel": "Your name"
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
"heading": "Une erreur est survenue lors du chargement de la page"
|
||||
},
|
||||
"feedbackAlert": "Donnez-nous votre avis",
|
||||
"forbidden": {
|
||||
"heading": "Accès interdit"
|
||||
},
|
||||
"languageSelector": {
|
||||
"buttonLabel": "Changer de langue (actuellement {{currentLanguage}})",
|
||||
"popoverLabel": "Choix de la langue"
|
||||
},
|
||||
"loading": "Chargement…",
|
||||
"loggedInUserTooltip": "Connecté en tant que…",
|
||||
"login": "Se connecter",
|
||||
|
||||
@@ -4,11 +4,24 @@
|
||||
"heading": "Aidez-nous à améliorer Meet"
|
||||
},
|
||||
"join": {
|
||||
"camlabel": "Webcam",
|
||||
"heading": "Rejoindre la réunion",
|
||||
"joinLabel": "Rejoindre",
|
||||
"micLabel": "Micro",
|
||||
"userLabel": "Votre nom"
|
||||
"cameraIsOff": "Webcam coupée.",
|
||||
"cameraIsOn": "Webcam activée.",
|
||||
"cameraPlaceholder": "Activez la webcam pour prévisualiser l'affichage",
|
||||
"camlabel": "",
|
||||
"chooseCamera": "Choisir la webcam",
|
||||
"chooseMic": "Choisir le micro",
|
||||
"chooseSpeaker": "Choisir la sortie audio",
|
||||
"heading": "Vérifiez vos paramètres",
|
||||
"joinLabel": "",
|
||||
"joinMeeting": "Rejoindre la réjoindre",
|
||||
"micIsOff": "Micro coupé.",
|
||||
"micIsOn": "Micro activé.",
|
||||
"micLabel": "",
|
||||
"toggleOff": "Cliquez pour désactiver",
|
||||
"toggleOn": "Cliquez pour activer",
|
||||
"userLabel": "",
|
||||
"usernameHint": "Affiché aux autres participants",
|
||||
"usernameLabel": "Votre nom"
|
||||
},
|
||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||
"shareDialog": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cva } from '@/styled-system/css'
|
||||
import { styled } from '../styled-system/jsx'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const box = cva({
|
||||
base: {
|
||||
|
||||
@@ -1,125 +1,20 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import {
|
||||
Button as RACButton,
|
||||
type ButtonProps as RACButtonsProps,
|
||||
TooltipTrigger,
|
||||
Link,
|
||||
LinkProps,
|
||||
} from 'react-aria-components'
|
||||
import { cva, type RecipeVariantProps } from '@/styled-system/css'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { type RecipeVariantProps } from '@/styled-system/css'
|
||||
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
||||
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
|
||||
|
||||
const button = cva({
|
||||
base: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
transition: 'background 200ms, outline 200ms, border-color 200ms',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
color: 'colorPalette.text',
|
||||
backgroundColor: 'colorPalette',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'colorPalette.hover',
|
||||
},
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'colorPalette.active',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
default: {
|
||||
borderRadius: 8,
|
||||
paddingX: '1',
|
||||
paddingY: '0.625',
|
||||
'--square-padding': '{spacing.0.625}',
|
||||
},
|
||||
sm: {
|
||||
borderRadius: 4,
|
||||
paddingX: '0.5',
|
||||
paddingY: '0.25',
|
||||
'--square-padding': '{spacing.0.25}',
|
||||
},
|
||||
xs: {
|
||||
borderRadius: 4,
|
||||
'--square-padding': '0',
|
||||
},
|
||||
},
|
||||
square: {
|
||||
true: {
|
||||
paddingX: 'var(--square-padding)',
|
||||
paddingY: 'var(--square-padding)',
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
default: {
|
||||
colorPalette: 'control',
|
||||
},
|
||||
primary: {
|
||||
colorPalette: 'primary',
|
||||
},
|
||||
// @TODO: better handling of colors… this is a mess
|
||||
success: {
|
||||
colorPalette: 'success',
|
||||
borderColor: 'success.300',
|
||||
color: 'success.subtle-text',
|
||||
backgroundColor: 'success.subtle',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'success.200',
|
||||
},
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'success.subtle!',
|
||||
},
|
||||
},
|
||||
},
|
||||
outline: {
|
||||
true: {
|
||||
color: 'colorPalette',
|
||||
backgroundColor: 'transparent!',
|
||||
borderColor: 'currentcolor!',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'colorPalette.subtle!',
|
||||
},
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'colorPalette.subtle!',
|
||||
},
|
||||
},
|
||||
},
|
||||
invisible: {
|
||||
true: {
|
||||
borderColor: 'none!',
|
||||
backgroundColor: 'none!',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'none!',
|
||||
borderColor: 'colorPalette.active!',
|
||||
},
|
||||
'&[data-pressed]': {
|
||||
borderColor: 'currentcolor',
|
||||
},
|
||||
},
|
||||
},
|
||||
fullWidth: {
|
||||
true: {
|
||||
width: 'full',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
outline: false,
|
||||
},
|
||||
})
|
||||
|
||||
type Tooltip = {
|
||||
tooltip?: string
|
||||
tooltipType?: 'instant' | 'delayed'
|
||||
}
|
||||
export type ButtonProps = RecipeVariantProps<typeof button> &
|
||||
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
RACButtonsProps &
|
||||
Tooltip
|
||||
TooltipWrapperProps
|
||||
|
||||
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps & Tooltip
|
||||
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
LinkProps &
|
||||
TooltipWrapperProps
|
||||
|
||||
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
|
||||
|
||||
@@ -128,37 +23,20 @@ export const Button = ({
|
||||
tooltipType = 'instant',
|
||||
...props
|
||||
}: ButtonOrLinkProps) => {
|
||||
const [variantProps, componentProps] = button.splitVariantProps(props)
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
if ((props as LinkButtonProps).href !== undefined) {
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<Link className={button(variantProps)} {...componentProps} />
|
||||
<Link className={buttonRecipe(variantProps)} {...componentProps} />
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<RACButton
|
||||
className={button(variantProps)}
|
||||
className={buttonRecipe(variantProps)}
|
||||
{...(componentProps as RACButtonsProps)}
|
||||
/>
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const TooltipWrapper = ({
|
||||
tooltip,
|
||||
tooltipType,
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
} & Tooltip) => {
|
||||
return tooltip ? (
|
||||
<TooltipTrigger delay={tooltipType === 'instant' ? 300 : 1000}>
|
||||
{children}
|
||||
<Tooltip>{tooltip}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ReactNode, useContext } from 'react'
|
||||
import { OverlayTriggerStateContext } from 'react-aria-components'
|
||||
|
||||
/**
|
||||
* Small helper you can use as a wrapper of a <Dialog> component if you want to make sure it is not rendered when it is closed.
|
||||
*
|
||||
* Not required all the time, it's mostly helpful to avoid calling hooks of a child component that uses a Dialog.
|
||||
*/
|
||||
export const DialogContainer = ({ children }: { children: ReactNode }) => {
|
||||
const state = useContext(OverlayTriggerStateContext)!
|
||||
return state.isOpen ? children : null
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Button, useCloseDialog } from '@/primitives'
|
||||
export const Form = ({
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
withSubmitButton = true,
|
||||
withCancelButton = true,
|
||||
onCancelButtonPress,
|
||||
children,
|
||||
@@ -25,6 +26,7 @@ export const Form = ({
|
||||
event: FormEvent<HTMLFormElement>
|
||||
) => void
|
||||
submitLabel: string
|
||||
withSubmitButton?: boolean
|
||||
withCancelButton?: boolean
|
||||
onCancelButtonPress?: () => void
|
||||
}) => {
|
||||
@@ -46,16 +48,18 @@ export const Form = ({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<HStack gap="gutter">
|
||||
<Button type="submit" variant="primary">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
{!!onCancel && (
|
||||
<Button variant="primary" outline onPress={() => onCancel()}>
|
||||
{t('cancel')}
|
||||
{withSubmitButton && (
|
||||
<HStack gap="gutter">
|
||||
<Button type="submit" variant="primary">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
{!!onCancel && (
|
||||
<Button variant="primary" outline onPress={() => onCancel()}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</RACForm>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { MenuTrigger } from 'react-aria-components'
|
||||
import { StyledPopover } from './Popover'
|
||||
import { Box } from './Box'
|
||||
|
||||
/**
|
||||
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
|
||||
*/
|
||||
export const Menu = ({
|
||||
children,
|
||||
}: {
|
||||
children: [trigger: ReactNode, menu: ReactNode]
|
||||
}) => {
|
||||
const [trigger, menu] = children
|
||||
return (
|
||||
<MenuTrigger>
|
||||
{trigger}
|
||||
<StyledPopover>
|
||||
<Box size="sm" type="popover">
|
||||
{menu}
|
||||
</Box>
|
||||
</StyledPopover>
|
||||
</MenuTrigger>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
|
||||
import { menuItemRecipe } from './menuItemRecipe'
|
||||
|
||||
/**
|
||||
* render a Button primitive that shows a popover showing a list of pressable items
|
||||
*/
|
||||
export const MenuList = <T extends string | number = string>({
|
||||
onAction,
|
||||
selectedItem,
|
||||
items = [],
|
||||
...menuProps
|
||||
}: {
|
||||
onAction: (key: T) => void
|
||||
selectedItem?: T
|
||||
items: Array<string | { value: T; label: ReactNode }>
|
||||
} & MenuProps<unknown>) => {
|
||||
return (
|
||||
<Menu
|
||||
selectionMode={selectedItem !== undefined ? 'single' : undefined}
|
||||
selectedKeys={selectedItem !== undefined ? [selectedItem] : undefined}
|
||||
{...menuProps}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const value = typeof item === 'string' ? item : item.value
|
||||
const label = typeof item === 'string' ? item : item.label
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuItemRecipe()}
|
||||
key={value}
|
||||
id={value as string}
|
||||
onAction={() => {
|
||||
onAction(value as T)
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -49,7 +49,10 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
||||
})
|
||||
|
||||
/**
|
||||
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some interactive content in a tooltip around the trigger
|
||||
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some content in a tooltip around the trigger
|
||||
*
|
||||
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
|
||||
* This is here when needing to show unrestricted content in a box.
|
||||
*/
|
||||
export const Popover = ({
|
||||
children,
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ReactNode, useContext } from 'react'
|
||||
import {
|
||||
ButtonProps,
|
||||
Button,
|
||||
OverlayTriggerStateContext,
|
||||
} from 'react-aria-components'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const ListItem = styled(Button, {
|
||||
base: {
|
||||
paddingY: 0.125,
|
||||
paddingX: 0.5,
|
||||
textAlign: 'left',
|
||||
width: 'full',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
color: 'box.text',
|
||||
border: '1px solid transparent',
|
||||
'&[data-selected]': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'&[data-focused]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primary',
|
||||
outline: 'none!',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primary',
|
||||
outline: 'none!',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* render a Button primitive that shows a popover showing a list of pressable items
|
||||
*/
|
||||
export const PopoverList = <T extends string | number = string>({
|
||||
onAction,
|
||||
closeOnAction = true,
|
||||
items = [],
|
||||
}: {
|
||||
closeOnAction?: boolean
|
||||
onAction: (key: T) => void
|
||||
items: Array<string | { key: string; value: T; label: ReactNode }>
|
||||
} & ButtonProps) => {
|
||||
const popoverState = useContext(OverlayTriggerStateContext)!
|
||||
return (
|
||||
<ul>
|
||||
{items.map((item) => {
|
||||
const value = typeof item === 'string' ? item : item.value
|
||||
const label = typeof item === 'string' ? item : item.label
|
||||
const key = typeof item === 'string' ? item : item.key
|
||||
return (
|
||||
<li key={key}>
|
||||
<ListItem
|
||||
key={value}
|
||||
onPress={() => {
|
||||
onAction(value as T)
|
||||
if (closeOnAction) {
|
||||
popoverState.close()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</ListItem>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'react-aria-components'
|
||||
import { Box } from './Box'
|
||||
import { StyledPopover } from './Popover'
|
||||
import { menuItemRecipe } from './menuItemRecipe'
|
||||
|
||||
const StyledButton = styled(Button, {
|
||||
base: {
|
||||
@@ -43,27 +44,6 @@ const StyledSelectValue = styled(SelectValue, {
|
||||
},
|
||||
})
|
||||
|
||||
const StyledListBoxItem = styled(ListBoxItem, {
|
||||
base: {
|
||||
paddingY: 0.125,
|
||||
paddingX: 0.5,
|
||||
textAlign: 'left',
|
||||
width: 'full',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
color: 'box.text',
|
||||
border: '1px solid transparent',
|
||||
'&[data-selected]': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'&[data-focused]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primary',
|
||||
outline: 'none!',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const Select = <T extends string | number>({
|
||||
label,
|
||||
items,
|
||||
@@ -85,9 +65,13 @@ export const Select = <T extends string | number>({
|
||||
<Box size="sm" type="popover" variant="control">
|
||||
<ListBox>
|
||||
{items.map((item) => (
|
||||
<StyledListBoxItem id={item.value} key={item.value}>
|
||||
<ListBoxItem
|
||||
className={menuItemRecipe()}
|
||||
id={item.value}
|
||||
key={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</StyledListBoxItem>
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
ToggleButton as RACToggleButton,
|
||||
ToggleButtonProps,
|
||||
} from 'react-aria-components'
|
||||
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
|
||||
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
|
||||
|
||||
/**
|
||||
* React aria ToggleButton with our button styles, that can take a tooltip if needed
|
||||
*/
|
||||
export const ToggleButton = ({
|
||||
tooltip,
|
||||
tooltipType,
|
||||
...props
|
||||
}: ToggleButtonProps & ButtonRecipeProps & TooltipWrapperProps) => {
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<RACToggleButton
|
||||
{...componentProps}
|
||||
className={buttonRecipe(variantProps)}
|
||||
/>
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
+30
-5
@@ -2,16 +2,41 @@ import { type ReactNode } from 'react'
|
||||
import {
|
||||
OverlayArrow,
|
||||
Tooltip as RACTooltip,
|
||||
TooltipProps,
|
||||
TooltipTrigger,
|
||||
type TooltipProps,
|
||||
} from 'react-aria-components'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
export type TooltipWrapperProps = {
|
||||
tooltip?: ReactNode
|
||||
tooltipType?: 'instant' | 'delayed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a component you want to apply a tooltip on (for example a Button)
|
||||
*
|
||||
* If no tooltip is given, just returns children
|
||||
*/
|
||||
export const TooltipWrapper = ({
|
||||
tooltip,
|
||||
tooltipType,
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
} & TooltipWrapperProps) => {
|
||||
return tooltip ? (
|
||||
<TooltipTrigger delay={tooltipType === 'instant' ? 300 : 1000}>
|
||||
{children}
|
||||
<Tooltip>{tooltip}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Styled react aria Tooltip component.
|
||||
*
|
||||
* Note that tooltips are directly handled by Buttons via the `tooltip` prop,
|
||||
* so you should not need to use this component directly.
|
||||
*
|
||||
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
|
||||
*/
|
||||
const StyledTooltip = styled(RACTooltip, {
|
||||
@@ -80,7 +105,7 @@ const TooltipArrow = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const Tooltip = ({
|
||||
const Tooltip = ({
|
||||
children,
|
||||
...props
|
||||
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { styled } from '../styled-system/jsx'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
export const Ul = styled('ul', {
|
||||
base: {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { type RecipeVariantProps, cva } from '@/styled-system/css'
|
||||
|
||||
export type ButtonRecipe = typeof buttonRecipe
|
||||
|
||||
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
|
||||
|
||||
export const buttonRecipe = cva({
|
||||
base: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
transition: 'background 200ms, outline 200ms, border-color 200ms',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
color: 'colorPalette.text',
|
||||
backgroundColor: 'colorPalette',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'colorPalette.hover',
|
||||
},
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'colorPalette.active',
|
||||
},
|
||||
'&[data-selected]': {
|
||||
backgroundColor: 'colorPalette.active',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
default: {
|
||||
borderRadius: 8,
|
||||
paddingX: '1',
|
||||
paddingY: '0.625',
|
||||
'--square-padding': '{spacing.0.625}',
|
||||
},
|
||||
sm: {
|
||||
borderRadius: 4,
|
||||
paddingX: '0.5',
|
||||
paddingY: '0.25',
|
||||
'--square-padding': '{spacing.0.25}',
|
||||
},
|
||||
xs: {
|
||||
borderRadius: 4,
|
||||
'--square-padding': '0',
|
||||
},
|
||||
},
|
||||
square: {
|
||||
true: {
|
||||
paddingX: 'var(--square-padding)',
|
||||
paddingY: 'var(--square-padding)',
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
default: {
|
||||
colorPalette: 'control',
|
||||
borderColor: 'control.subtle',
|
||||
},
|
||||
primary: {
|
||||
colorPalette: 'primary',
|
||||
},
|
||||
// @TODO: better handling of colors… this is a mess
|
||||
success: {
|
||||
colorPalette: 'success',
|
||||
borderColor: 'success.300',
|
||||
color: 'success.subtle-text',
|
||||
backgroundColor: 'success.subtle',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'success.200',
|
||||
},
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'success.subtle!',
|
||||
},
|
||||
},
|
||||
danger: {
|
||||
colorPalette: 'danger',
|
||||
borderColor: 'danger.600',
|
||||
color: 'danger.subtle-text',
|
||||
backgroundColor: 'danger.subtle',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'danger.200',
|
||||
},
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'danger.subtle!',
|
||||
},
|
||||
},
|
||||
},
|
||||
outline: {
|
||||
true: {
|
||||
color: 'colorPalette',
|
||||
backgroundColor: 'transparent!',
|
||||
borderColor: 'currentcolor!',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'colorPalette.subtle!',
|
||||
},
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'colorPalette.subtle!',
|
||||
},
|
||||
},
|
||||
},
|
||||
invisible: {
|
||||
true: {
|
||||
borderColor: 'none!',
|
||||
backgroundColor: 'none!',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'none!',
|
||||
borderColor: 'colorPalette.active!',
|
||||
},
|
||||
'&[data-pressed]': {
|
||||
borderColor: 'currentcolor',
|
||||
},
|
||||
},
|
||||
},
|
||||
fullWidth: {
|
||||
true: {
|
||||
width: 'full',
|
||||
},
|
||||
},
|
||||
/**
|
||||
* if the button is next to other ones to make a "button group", tell where the button is to handle radius
|
||||
*/
|
||||
groupPosition: {
|
||||
left: {
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
right: {
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderLeft: 0,
|
||||
},
|
||||
center: {
|
||||
borderRadius: 0,
|
||||
},
|
||||
},
|
||||
// some toggle buttons make more sense without a "pushed button" style when enabled because their content changes to mark the state
|
||||
toggledStyles: {
|
||||
false: {
|
||||
'&[data-selected]': {
|
||||
backgroundColor: 'colorPalette',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
outline: false,
|
||||
toggledStyles: true,
|
||||
},
|
||||
})
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* exposes all primitives we want to use in other parts of the app.
|
||||
*
|
||||
* It's intended not everything is exported: some primitives are meant as building-blocks
|
||||
* for other primitives and don't have any value being exposed.
|
||||
*/
|
||||
export { A } from './A'
|
||||
export { Badge } from './Badge'
|
||||
export { Bold } from './Bold'
|
||||
@@ -5,6 +11,7 @@ export { Box } from './Box'
|
||||
export { Button } from './Button'
|
||||
export { useCloseDialog } from './useCloseDialog'
|
||||
export { Dialog, type DialogProps } from './Dialog'
|
||||
export { DialogContainer } from './DialogContainer'
|
||||
export { Div } from './Div'
|
||||
export { Field } from './Field'
|
||||
export { Form } from './Form'
|
||||
@@ -13,9 +20,11 @@ export { Hr } from './Hr'
|
||||
export { Italic } from './Italic'
|
||||
export { Input } from './Input'
|
||||
export { Link } from './Link'
|
||||
export { Menu } from './Menu'
|
||||
export { MenuList } from './MenuList'
|
||||
export { P } from './P'
|
||||
export { Popover } from './Popover'
|
||||
export { PopoverList } from './PopoverList'
|
||||
export { Text } from './Text'
|
||||
export { ToggleButton } from './ToggleButton'
|
||||
export { Ul } from './Ul'
|
||||
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cva } from '@/styled-system/css'
|
||||
|
||||
/**
|
||||
* reusable styles for a menu item, select item, etc… to be used with panda `css()` or `styled()`
|
||||
*
|
||||
* these are in their own files because react hot refresh doesn't like exporting stuff
|
||||
* that aren't components in component files
|
||||
*/
|
||||
export const menuItemRecipe = cva({
|
||||
base: {
|
||||
paddingY: 0.125,
|
||||
paddingX: 0.5,
|
||||
paddingLeft: 1.5,
|
||||
textAlign: 'left',
|
||||
width: 'full',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
color: 'box.text',
|
||||
border: '1px solid transparent',
|
||||
position: 'relative',
|
||||
'&[data-selected]': {
|
||||
'&::before': {
|
||||
content: '"✓"',
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
left: '6px',
|
||||
},
|
||||
},
|
||||
'&[data-focused]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primary',
|
||||
outline: 'none!',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primary',
|
||||
outline: 'none!',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user