Move profile field elements into their own files

This commit is contained in:
Ginger 2025-09-18 12:34:34 -04:00
parent 317cd366c3
commit 8a8443bda4
No known key found for this signature in database
6 changed files with 475 additions and 495 deletions

View file

@ -1,63 +1,31 @@
import React, {
ChangeEventHandler,
FormEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Input,
Button,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
config,
Spinner,
toRem,
Dialog,
Header,
MenuItem,
Chip,
PopOut,
RectCords,
Menu,
Line,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
import { ImageEditor } from '../../../components/image-editor';
import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
import {
ExtendedProfile,
profileEditsAllowed,
useExtendedProfile,
} from '../../../hooks/useExtendedProfile';
import { ProfileFieldContext, ProfileFieldElementProps } from './ProfileFieldContext';
import { ProfileFieldContext, ProfileFieldElementProps } from './fields/ProfileFieldContext';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { FilterByValues } from '../../../../types/utils';
import { CutoutCard } from '../../../components/cutout-card';
import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips';
import { SequenceCardStyle } from '../styles.css';
@ -66,467 +34,12 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { withSearchParam } from '../../../pages/pathUtils';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { ProfileAvatar } from './fields/ProfileAvatar';
import { ProfileTextField } from './fields/ProfileTextField';
import { ProfilePronouns } from './fields/ProfilePronouns';
import { ProfileTimezone } from './fields/ProfileTimezone';
type FieldContext = { busy: boolean };
function ProfileAvatar({
busy,
value,
setValue,
}: ProfileFieldElementProps<'avatar_url', FieldContext>) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarUrl = value
? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const disabled = busy;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
setValue(mxc);
handleRemoveUpload();
},
[setValue, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
setValue('');
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Avatar
</Text>
}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disabled}
>
<Text size="B300">Upload Avatar</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disabled}
onClick={handleRemoveAvatar}
>
<Text size="B300">Remove Avatar</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</SettingTile>
);
}
function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
label,
defaultValue,
value,
setValue,
busy,
}: ProfileFieldElementProps<K, FieldContext> & { label: string }) {
const disabled = busy;
const hasChanges = defaultValue !== value;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const content = evt.currentTarget.value;
if (content.length > 0) {
setValue(evt.currentTarget.value);
} else {
setValue(undefined);
}
};
const handleReset = () => {
setValue(defaultValue);
};
return (
<SettingTile
title={
<Text as="span" size="L400">
{label}
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box gap="200" aria-disabled={disabled}>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={value ?? ''}
onChange={handleChange}
variant="Secondary"
radii="300"
disabled={disabled}
readOnly={disabled}
after={
hasChanges &&
!busy && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
</Box>
</Box>
</SettingTile>
);
}
function ProfilePronouns({
value,
setValue,
busy,
}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) {
const disabled = busy;
const [menuCords, setMenuCords] = useState<RectCords>();
const [pendingPronoun, setPendingPronoun] = useState('');
const handleRemovePronoun = (index: number) => {
const newPronouns = [...(value ?? [])];
newPronouns.splice(index, 1);
if (newPronouns.length > 0) {
setValue(newPronouns);
} else {
setValue(undefined);
}
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
setMenuCords(undefined);
if (pendingPronoun.length > 0) {
setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]);
}
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('escape', evt)) {
evt.stopPropagation();
setMenuCords(undefined);
}
};
const handleOpenMenu: MouseEventHandler<HTMLSpanElement> = (evt) => {
setPendingPronoun('');
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Pronouns
</Text>
}
>
<Box alignItems="Center" gap="200" wrap="Wrap">
{value?.map(({ summary }, index) => (
<Chip
// eslint-disable-next-line react/no-array-index-key
key={index}
variant="Secondary"
radii="Pill"
after={<Icon src={Icons.Cross} size="100" />}
onClick={() => handleRemovePronoun(index)}
disabled={disabled}
>
<Text size="T200" truncate>
{summary}
</Text>
</Chip>
))}
<Chip
variant="Secondary"
radii="Pill"
disabled={disabled}
after={<Icon src={menuCords ? Icons.ChevronRight : Icons.Plus} size="100" />}
onClick={handleOpenMenu}
>
<Text size="T200">Add</Text>
</Chip>
</Box>
<PopOut
anchor={menuCords}
offset={5}
position="Right"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu
variant="SurfaceVariant"
style={{
padding: config.space.S200,
}}
>
<Box as="form" onSubmit={handleSubmit} direction="Row" gap="200">
<Input
variant="Secondary"
placeholder="they/them"
inputSize={10}
radii="300"
size="300"
outlined
value={pendingPronoun}
onChange={(evt) => setPendingPronoun(evt.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
<Button
type="submit"
size="300"
variant="Success"
radii="300"
before={<Icon size="100" src={Icons.Plus} />}
>
<Text size="B300">Add</Text>
</Button>
</Box>
</Menu>
</FocusTrap>
}
/>
</SettingTile>
);
}
function ProfileTimezone({
value,
setValue,
busy,
}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) {
const disabled = busy;
const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [overlayOpen, setOverlayOpen] = useState(false);
const [query, setQuery] = useState('');
// @ts-expect-error Intl.supportedValuesOf isn't in the types yet
const timezones = useMemo(() => Intl.supportedValuesOf('timeZone') as string[], []);
const filteredTimezones = timezones.filter(
(timezone) =>
query.length === 0 || timezone.toLowerCase().replace('_', ' ').includes(query.toLowerCase())
);
const handleSelect = useCallback(
(timezone: string) => {
setOverlayOpen(false);
setValue(timezone);
},
[setOverlayOpen, setValue]
);
useEffect(() => {
if (overlayOpen) {
const scrollView = scrollRef.current;
const focusedItem = scrollView?.querySelector(`[data-tz="${value}"]`);
if (value && focusedItem && scrollView) {
focusedItem.scrollIntoView({
block: 'center',
});
}
}
}, [scrollRef, value, overlayOpen]);
return (
<SettingTile
title={
<Text as="span" size="L400">
Timezone
</Text>
}
>
<Overlay open={overlayOpen} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: () => inputRef.current,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: () => setOverlayOpen(false),
escapeDeactivates: (evt) => {
evt.stopPropagation();
return true;
},
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Choose a Timezone</Text>
</Box>
<IconButton size="300" onClick={() => setOverlayOpen(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Input
ref={inputRef}
size="500"
variant="Background"
radii="400"
outlined
placeholder="Search"
before={<Icon size="200" src={Icons.Search} />}
value={query}
onChange={(evt) => setQuery(evt.currentTarget.value)}
/>
<CutoutCard ref={scrollRef} style={{ overflowY: 'scroll', height: toRem(300) }}>
{filteredTimezones.length === 0 && (
<Box
style={{ paddingTop: config.space.S700 }}
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
No Results
</Text>
</Box>
)}
{filteredTimezones.map((timezone) => (
<MenuItem
key={timezone}
data-tz={timezone}
variant={timezone === value ? 'Success' : 'Surface'}
fill={timezone === value ? 'Soft' : 'None'}
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => handleSelect(timezone)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{timezone}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Box gap="200">
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={disabled}
onClick={() => setOverlayOpen(true)}
after={<Icon size="100" src={Icons.ChevronRight} />}
>
<Text size="B300">{value ?? 'Set Timezone'}</Text>
</Button>
{value && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disabled}
onClick={() => setValue(undefined)}
>
<Text size="B300">Remove Timezone</Text>
</Button>
)}
</Box>
</SettingTile>
);
}
export type FieldContext = { busy: boolean };
function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) {
const accountManagementActions = useAccountManagementActions();
@ -600,7 +113,7 @@ export function Profile() {
}).filter(([key]) => profileEditsAllowed(key, capabilities, extendedProfileSupported));
return [Object.fromEntries(entries), entries.length > 0];
}, [capabilities, extendedProfileSupported]);
const [fieldDefaults, setFieldDefaults] = useState<ExtendedProfile>({
displayname: legacyProfile.displayName,
avatar_url: legacyProfile.avatarUrl,

View file

@ -0,0 +1,118 @@
import FocusTrap from 'focus-trap-react';
import { Text, Box, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal } from 'folds';
import React, { useState, useMemo, useCallback } from 'react';
import { ImageEditor } from '../../../../components/image-editor';
import { SettingTile } from '../../../../components/setting-tile';
import { CompactUploadCardRenderer } from '../../../../components/upload-card';
import { useFilePicker } from '../../../../hooks/useFilePicker';
import { useMatrixClient } from '../../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../../hooks/useMediaAuthentication';
import { useObjectURL } from '../../../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../../../state/upload';
import { stopPropagation } from '../../../../utils/keyboard';
import { mxcUrlToHttp } from '../../../../utils/matrix';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
import { ModalWide } from '../../../../styles/Modal.css';
export function ProfileAvatar({
busy, value, setValue,
}: ProfileFieldElementProps<'avatar_url', FieldContext>) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarUrl = value
? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const disabled = busy;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
setValue(mxc);
handleRemoveUpload();
},
[setValue, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
setValue('');
};
return (
<SettingTile
title={<Text as="span" size="L400">
Avatar
</Text>}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded} />
</Box>
) : (
<Box gap="200">
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disabled}
>
<Text size="B300">Upload Avatar</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disabled}
onClick={handleRemoveAvatar}
>
<Text size="B300">Remove Avatar</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload} />
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</SettingTile>
);
}

View file

@ -1,5 +1,5 @@
import React, { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { ExtendedProfile } from '../../../hooks/useExtendedProfile';
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
type ExtendedProfileKeys = keyof {
[Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property]
@ -61,6 +61,7 @@ export function ProfileFieldContext<C>({
defaultValue: fieldDefaults[key],
value: fields[key],
setValue: (value) => setField(key, value),
key,
};
if (element !== undefined) {
return React.createElement(element, props);

View file

@ -0,0 +1,125 @@
import FocusTrap from 'focus-trap-react';
import { RectCords, Text, Box, Chip, Icon, Icons, PopOut, Menu, config, Input, Button } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import React, { useState, FormEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react';
import { SettingTile } from '../../../../components/setting-tile';
import { stopPropagation } from '../../../../utils/keyboard';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
export function ProfilePronouns({
value, setValue, busy,
}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) {
const disabled = busy;
const [menuCords, setMenuCords] = useState<RectCords>();
const [pendingPronoun, setPendingPronoun] = useState('');
const handleRemovePronoun = (index: number) => {
const newPronouns = [...(value ?? [])];
newPronouns.splice(index, 1);
if (newPronouns.length > 0) {
setValue(newPronouns);
} else {
setValue(undefined);
}
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
setMenuCords(undefined);
if (pendingPronoun.length > 0) {
setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]);
}
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('escape', evt)) {
evt.stopPropagation();
setMenuCords(undefined);
}
};
const handleOpenMenu: MouseEventHandler<HTMLSpanElement> = (evt) => {
setPendingPronoun('');
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
return (
<SettingTile
title={<Text as="span" size="L400">
Pronouns
</Text>}
>
<Box alignItems="Center" gap="200" wrap="Wrap">
{value?.map(({ summary }, index) => (
<Chip
// eslint-disable-next-line react/no-array-index-key
key={index}
variant="Secondary"
radii="Pill"
after={<Icon src={Icons.Cross} size="100" />}
onClick={() => handleRemovePronoun(index)}
disabled={disabled}
>
<Text size="T200" truncate>
{summary}
</Text>
</Chip>
))}
<Chip
variant="Secondary"
radii="Pill"
disabled={disabled}
after={<Icon src={menuCords ? Icons.ChevronRight : Icons.Plus} size="100" />}
onClick={handleOpenMenu}
>
<Text size="T200">Add</Text>
</Chip>
</Box>
<PopOut
anchor={menuCords}
offset={5}
position="Right"
align="Center"
content={<FocusTrap
focusTrapOptions={{
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu
variant="SurfaceVariant"
style={{
padding: config.space.S200,
}}
>
<Box as="form" onSubmit={handleSubmit} direction="Row" gap="200">
<Input
variant="Secondary"
placeholder="they/them"
inputSize={10}
radii="300"
size="300"
outlined
value={pendingPronoun}
onChange={(evt) => setPendingPronoun(evt.currentTarget.value)}
onKeyDown={handleKeyDown} />
<Button
type="submit"
size="300"
variant="Success"
radii="300"
before={<Icon size="100" src={Icons.Plus} />}
>
<Text size="B300">Add</Text>
</Button>
</Box>
</Menu>
</FocusTrap>} />
</SettingTile>
);
}

View file

@ -0,0 +1,63 @@
import { Text, Box, Input, IconButton, Icon, Icons } from 'folds';
import React, { ChangeEventHandler } from 'react';
import { FilterByValues } from '../../../../../types/utils';
import { SettingTile } from '../../../../components/setting-tile';
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
export function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
label, defaultValue, value, setValue, busy,
}: ProfileFieldElementProps<K, FieldContext> & { label: string; }) {
const disabled = busy;
const hasChanges = defaultValue !== value;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const content = evt.currentTarget.value;
if (content.length > 0) {
setValue(evt.currentTarget.value);
} else {
setValue(undefined);
}
};
const handleReset = () => {
setValue(defaultValue);
};
return (
<SettingTile
title={<Text as="span" size="L400">
{label}
</Text>}
>
<Box direction="Column" grow="Yes" gap="100">
<Box gap="200" aria-disabled={disabled}>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={value ?? ''}
onChange={handleChange}
variant="Secondary"
radii="300"
disabled={disabled}
readOnly={disabled}
after={hasChanges &&
!busy && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)} />
</Box>
</Box>
</Box>
</SettingTile>
);
}

View file

@ -0,0 +1,160 @@
import FocusTrap from 'focus-trap-react';
import { Text, Overlay, OverlayBackdrop, OverlayCenter, Dialog, Header, config, Box, IconButton, Icon, Icons, Input, toRem, MenuItem, Button } from 'folds';
import React, { useRef, useState, useMemo, useCallback, useEffect } from 'react';
import { CutoutCard } from '../../../../components/cutout-card';
import { SettingTile } from '../../../../components/setting-tile';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
export function ProfileTimezone({
value, setValue, busy,
}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) {
const disabled = busy;
const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [overlayOpen, setOverlayOpen] = useState(false);
const [query, setQuery] = useState('');
// @ts-expect-error Intl.supportedValuesOf isn't in the types yet
const timezones = useMemo(() => Intl.supportedValuesOf('timeZone') as string[], []);
const filteredTimezones = timezones.filter(
(timezone) => query.length === 0 || timezone.toLowerCase().replace('_', ' ').includes(query.toLowerCase())
);
const handleSelect = useCallback(
(timezone: string) => {
setOverlayOpen(false);
setValue(timezone);
},
[setOverlayOpen, setValue]
);
useEffect(() => {
if (overlayOpen) {
const scrollView = scrollRef.current;
const focusedItem = scrollView?.querySelector(`[data-tz="${value}"]`);
if (value && focusedItem && scrollView) {
focusedItem.scrollIntoView({
block: 'center',
});
}
}
}, [scrollRef, value, overlayOpen]);
return (
<SettingTile
title={<Text as="span" size="L400">
Timezone
</Text>}
>
<Overlay open={overlayOpen} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: () => inputRef.current,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: () => setOverlayOpen(false),
escapeDeactivates: (evt) => {
evt.stopPropagation();
return true;
},
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Choose a Timezone</Text>
</Box>
<IconButton size="300" onClick={() => setOverlayOpen(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Input
ref={inputRef}
size="500"
variant="Background"
radii="400"
outlined
placeholder="Search"
before={<Icon size="200" src={Icons.Search} />}
value={query}
onChange={(evt) => setQuery(evt.currentTarget.value)} />
<CutoutCard ref={scrollRef} style={{ overflowY: 'scroll', height: toRem(300) }}>
{filteredTimezones.length === 0 && (
<Box
style={{ paddingTop: config.space.S700 }}
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
No Results
</Text>
</Box>
)}
{filteredTimezones.map((timezone) => (
<MenuItem
key={timezone}
data-tz={timezone}
variant={timezone === value ? 'Success' : 'Surface'}
fill={timezone === value ? 'Soft' : 'None'}
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => handleSelect(timezone)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{timezone}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Box gap="200">
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={disabled}
onClick={() => setOverlayOpen(true)}
after={<Icon size="100" src={Icons.ChevronRight} />}
>
<Text size="B300">{value ?? 'Set Timezone'}</Text>
</Button>
{value && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disabled}
onClick={() => setValue(undefined)}
>
<Text size="B300">Remove Timezone</Text>
</Button>
)}
</Box>
</SettingTile>
);
}