Replace remaining React ARIA components with Compound components (#2576)
* Fix issues detected by Knip Including cleaning up some unused code and dependencies, using a React hook that we unintentionally stopped using, and also adding some previously undeclared dependencies. * Replace remaining React ARIA components with Compound components * fix button position * disable scrollbars to resolve overlapping button --------- Co-authored-by: Timo <toger5@hotmail.de>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
Copyright 2022-2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -15,45 +15,30 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
.avatarInputField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatarContainer {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
width: 0.1px;
|
||||
height: 0.1px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edit {
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
padding: 2px;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset-block-end: -2px;
|
||||
inset-inline-end: -2px;
|
||||
}
|
||||
|
||||
.fileInput:focus + .fileInputButton {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
.fileInputButton {
|
||||
position: absolute;
|
||||
bottom: 11px;
|
||||
right: -4px;
|
||||
background-color: var(--cpd-color-subtle-primary);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
color: var(--cpd-color-text-action-accent);
|
||||
font-size: var(--font-size-caption);
|
||||
padding: 6px 0;
|
||||
.edit button {
|
||||
min-block-size: 0;
|
||||
block-size: var(--cpd-space-7x);
|
||||
inline-size: var(--cpd-space-7x);
|
||||
padding: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
Copyright 2022-2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,21 +14,25 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useObjectRef } from "@react-aria/utils";
|
||||
import {
|
||||
AllHTMLAttributes,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useState,
|
||||
forwardRef,
|
||||
ChangeEvent,
|
||||
useRef,
|
||||
FC,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Menu, MenuItem } from "@vector-im/compound-web";
|
||||
import {
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
ShareIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { Avatar, Size } from "../Avatar";
|
||||
import { Button } from "../button";
|
||||
import EditIcon from "../icons/Edit.svg?react";
|
||||
import styles from "./AvatarInputField.module.css";
|
||||
|
||||
interface Props extends AllHTMLAttributes<HTMLInputElement> {
|
||||
@@ -40,89 +44,115 @@ interface Props extends AllHTMLAttributes<HTMLInputElement> {
|
||||
onRemoveAvatar: () => void;
|
||||
}
|
||||
|
||||
export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
label,
|
||||
className,
|
||||
avatarUrl,
|
||||
userId,
|
||||
displayName,
|
||||
onRemoveAvatar,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
export const AvatarInputField: FC<Props> = ({
|
||||
id,
|
||||
label,
|
||||
className,
|
||||
avatarUrl,
|
||||
userId,
|
||||
displayName,
|
||||
onRemoveAvatar,
|
||||
...rest
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [removed, setRemoved] = useState(false);
|
||||
const [objUrl, setObjUrl] = useState<string | undefined>(undefined);
|
||||
const [removed, setRemoved] = useState(false);
|
||||
const [objUrl, setObjUrl] = useState<string | undefined>(undefined);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const fileInputRef = useObjectRef(ref);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentInput = fileInputRef.current;
|
||||
useEffect(() => {
|
||||
const currentInput = fileInputRef.current!;
|
||||
|
||||
const onChange = (e: Event): void => {
|
||||
const inputEvent = e as unknown as ChangeEvent<HTMLInputElement>;
|
||||
if (inputEvent.target.files && inputEvent.target.files.length > 0) {
|
||||
setObjUrl(URL.createObjectURL(inputEvent.target.files[0]));
|
||||
setRemoved(false);
|
||||
} else {
|
||||
setObjUrl(undefined);
|
||||
}
|
||||
};
|
||||
const onChange = (e: Event): void => {
|
||||
const inputEvent = e as unknown as ChangeEvent<HTMLInputElement>;
|
||||
if (inputEvent.target.files && inputEvent.target.files.length > 0) {
|
||||
setObjUrl(URL.createObjectURL(inputEvent.target.files[0]));
|
||||
setRemoved(false);
|
||||
} else {
|
||||
setObjUrl(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
currentInput.addEventListener("change", onChange);
|
||||
currentInput.addEventListener("change", onChange);
|
||||
|
||||
return (): void => {
|
||||
currentInput?.removeEventListener("change", onChange);
|
||||
};
|
||||
});
|
||||
return (): void => {
|
||||
currentInput?.removeEventListener("change", onChange);
|
||||
};
|
||||
});
|
||||
|
||||
const onPressRemoveAvatar = useCallback(() => {
|
||||
setRemoved(true);
|
||||
onRemoveAvatar();
|
||||
}, [onRemoveAvatar]);
|
||||
const onSelectUpload = useCallback(() => {
|
||||
fileInputRef.current!.click();
|
||||
}, [fileInputRef]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.avatarInputField, className)}>
|
||||
<div className={styles.avatarContainer}>
|
||||
<Avatar
|
||||
id={userId}
|
||||
name={displayName}
|
||||
size={Size.XL}
|
||||
src={removed ? undefined : objUrl || avatarUrl}
|
||||
/>
|
||||
<input
|
||||
id={id}
|
||||
accept="image/png, image/jpeg"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className={styles.fileInput}
|
||||
role="button"
|
||||
aria-label={label}
|
||||
{...rest}
|
||||
/>
|
||||
{/* https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/issues/966 */}
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label htmlFor={id} className={styles.fileInputButton}>
|
||||
<EditIcon />
|
||||
</label>
|
||||
</div>
|
||||
{(avatarUrl || objUrl) && !removed && (
|
||||
<Button
|
||||
className={styles.removeButton}
|
||||
variant="icon"
|
||||
onPress={onPressRemoveAvatar}
|
||||
const onSelectRemove = useCallback(() => {
|
||||
setRemoved(true);
|
||||
onRemoveAvatar();
|
||||
}, [onRemoveAvatar]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.avatarInputField, className)}>
|
||||
<Avatar
|
||||
id={userId}
|
||||
className={styles.avatar}
|
||||
name={displayName}
|
||||
size={Size.XL}
|
||||
src={removed ? undefined : objUrl || avatarUrl}
|
||||
/>
|
||||
<input
|
||||
id={id}
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className={styles.fileInput}
|
||||
role="button"
|
||||
aria-label={label}
|
||||
{...rest}
|
||||
/>
|
||||
<div className={styles.edit}>
|
||||
{(avatarUrl || objUrl) && !removed ? (
|
||||
<Menu
|
||||
title={t("action.edit")}
|
||||
showTitle={false}
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
trigger={
|
||||
<Button
|
||||
iconOnly
|
||||
Icon={EditIcon}
|
||||
kind="tertiary"
|
||||
size="sm"
|
||||
aria-label={t("action.edit")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("action.remove")}
|
||||
</Button>
|
||||
<MenuItem
|
||||
Icon={ShareIcon}
|
||||
label={t("action.upload_file")}
|
||||
onSelect={onSelectUpload}
|
||||
/>
|
||||
<MenuItem
|
||||
Icon={DeleteIcon}
|
||||
label={t("action.remove")}
|
||||
kind="critical"
|
||||
onSelect={onSelectRemove}
|
||||
/>
|
||||
</Menu>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
iconOnly
|
||||
Icon={EditIcon}
|
||||
kind="tertiary"
|
||||
size="sm"
|
||||
aria-label={t("action.edit")}
|
||||
onClick={onSelectUpload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AvatarInputField.displayName = "AvatarInputField";
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.selectInput {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: 28px;
|
||||
max-width: 444px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.selectTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--cpd-color-border-interactive-primary);
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--cpd-color-text-primary);
|
||||
height: 40px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selectTrigger:focus {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
.selectedItem {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
margin-top: 5px;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { AriaSelectOptions, HiddenSelect, useSelect } from "@react-aria/select";
|
||||
import { useButton } from "@react-aria/button";
|
||||
import { useSelectState } from "@react-stately/select";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Popover } from "../popover/Popover";
|
||||
import { ListBox } from "../ListBox";
|
||||
import styles from "./SelectInput.module.css";
|
||||
import ArrowDownIcon from "../icons/ArrowDown.svg?react";
|
||||
|
||||
interface Props extends AriaSelectOptions<object> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SelectInput(props: Props): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const state = useSelectState(props);
|
||||
|
||||
const ref = useRef(null);
|
||||
const { labelProps, triggerProps, valueProps, menuProps } = useSelect(
|
||||
props,
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
const { buttonProps } = useButton(triggerProps, ref);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.selectInput, props.className)}>
|
||||
<h4 {...labelProps} className={styles.label}>
|
||||
{props.label}
|
||||
</h4>
|
||||
<HiddenSelect
|
||||
state={state}
|
||||
triggerRef={ref}
|
||||
label={props.label}
|
||||
name={props.name}
|
||||
/>
|
||||
<button {...buttonProps} ref={ref} className={styles.selectTrigger}>
|
||||
<span {...valueProps} className={styles.selectedItem}>
|
||||
{state.selectedItem
|
||||
? state.selectedItem.rendered
|
||||
: t("select_input_unset_button")}
|
||||
</span>
|
||||
<ArrowDownIcon />
|
||||
</button>
|
||||
{state.isOpen && (
|
||||
<Popover
|
||||
isOpen={state.isOpen}
|
||||
onClose={state.close}
|
||||
className={styles.popover}
|
||||
>
|
||||
<ListBox
|
||||
{...menuProps}
|
||||
state={state}
|
||||
optionClassName={styles.option}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user