InputSelectPermission component (#9585)

This commit is contained in:
Hemachandar
2025-07-10 18:16:19 +05:30
committed by GitHub
parent 7dd0616b8c
commit d4cdf4202f
8 changed files with 288 additions and 234 deletions

View File

@@ -14,7 +14,7 @@ import Collection from "~/models/Collection";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Input from "~/components/Input"; import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission"; import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad"; import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch"; import Switch from "~/components/Switch";
import Text from "~/components/Text"; import Text from "~/components/Text";
@@ -172,7 +172,7 @@ export const CollectionForm = observer(function CollectionForm_({
) => { ) => {
field.onChange(value === EmptySelectValue ? null : value); field.onChange(value === EmptySelectValue ? null : value);
}} }}
note={t( help={t(
"The default access for workspace members, you can share with more users or groups later." "The default access for workspace members, you can share with more users or groups later."
)} )}
/> />

View File

@@ -1,14 +1,18 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { QuestionMarkIcon } from "outline-icons";
import { transparentize } from "polished"; import { transparentize } from "polished";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text"; import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile"; import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator"; import Separator from "./ContextMenu/Separator";
import Flex from "./Flex"; import Flex from "./Flex";
import { LabelText } from "./Input"; import { LabelText } from "./Input";
import NudeButton from "./NudeButton";
import Scrollable from "./Scrollable"; import Scrollable from "./Scrollable";
import { IconWrapper } from "./Sidebar/components/SidebarLink"; import { IconWrapper } from "./Sidebar/components/SidebarLink";
import Tooltip from "./Tooltip";
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@@ -53,7 +57,7 @@ type Props = {
/* Options to display in the select menu. */ /* Options to display in the select menu. */
options: Option[]; options: Option[];
/* Current chosen value. */ /* Current chosen value. */
value?: string; value?: string | null;
/* Callback when an option is selected. */ /* Callback when an option is selected. */
onChange: (value: string) => void; onChange: (value: string) => void;
/* ARIA label for accessibility. */ /* ARIA label for accessibility. */
@@ -66,231 +70,259 @@ type Props = {
disabled?: boolean; disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */ /* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
short?: boolean; short?: boolean;
/** Display a tooltip with the descriptive help text about the select menu. */
help?: string;
} & TriggerButtonProps; } & TriggerButtonProps;
export function InputSelectNew(props: Props) { export const InputSelectNew = React.forwardRef<HTMLButtonElement, Props>(
const { (props, ref) => {
options, const {
value, options,
onChange, value,
ariaLabel, onChange,
label, ariaLabel,
hideLabel, label,
disabled, hideLabel,
short, short,
...triggerProps help,
} = props; ...triggerProps
} = props;
const [localValue, setLocalValue] = React.useState(value); const [localValue, setLocalValue] = React.useState(value);
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const triggerRef = const contentRef =
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null); React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const contentRef =
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const isMobile = useMobile(); const isMobile = useMobile();
const placeholder = `Select a ${ariaLabel.toLowerCase()}`; const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
const optionsHaveIcon = options.some( const optionsHaveIcon = options.some(
(opt) => opt.type === "item" && !!opt.icon (opt) => opt.type === "item" && !!opt.icon
); );
const renderOption = React.useCallback( const renderOption = React.useCallback(
(option: Option) => { (option: Option) => {
if (option.type === "separator") { if (option.type === "separator") {
return <InputSelectSeparator />; return <InputSelectSeparator />;
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
</InputSelectItem>
);
},
[optionsHaveIcon]
);
const onValueChange = React.useCallback(
async (val: string) => {
setLocalValue(val);
onChange(val);
},
[onChange, setLocalValue]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
} }
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
if (isMobile) {
return ( return (
<InputSelectItem key={option.value} value={option.value}> <MobileSelect
<Option option={option} optionsHaveIcon={optionsHaveIcon} /> ref={ref}
</InputSelectItem> {...props}
value={localValue}
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
/>
); );
},
[optionsHaveIcon]
);
const onValueChange = React.useCallback(
async (val: string) => {
setLocalValue(val);
onChange(val);
},
[onChange, setLocalValue]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
} }
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
if (isMobile) {
return ( return (
<MobileSelect <Wrapper short={short}>
{...props} <Label text={label} hidden={hideLabel ?? false} help={help} />
value={localValue} <InputSelectRoot
onChange={onValueChange} open={open}
placeholder={placeholder} onOpenChange={setOpen}
optionsHaveIcon={optionsHaveIcon} value={localValue ?? undefined}
/> onValueChange={onValueChange}
>
<InputSelectTrigger
ref={ref}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
); );
} }
);
return ( InputSelectNew.displayName = "InputSelect";
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
value={localValue}
onValueChange={onValueChange}
>
<InputSelectTrigger
ref={triggerRef}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
);
}
type MobileSelectProps = Props & { type MobileSelectProps = Props & {
placeholder: string; placeholder: string;
optionsHaveIcon: boolean; optionsHaveIcon: boolean;
}; };
function MobileSelect(props: MobileSelectProps) { const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
const { (props, ref) => {
options, const {
value, options,
onChange, value,
ariaLabel, onChange,
label, ariaLabel,
hideLabel, label,
disabled, hideLabel,
short, disabled,
placeholder, short,
optionsHaveIcon, placeholder,
...triggerProps optionsHaveIcon,
} = props; ...triggerProps
} = props;
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null); const contentRef =
React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const selectedOption = React.useMemo( const selectedOption = React.useMemo(
() => () =>
value value
? options.find((opt) => opt.type === "item" && opt.value === value) ? options.find((opt) => opt.type === "item" && opt.value === value)
: undefined, : undefined,
[value, options] [value, options]
); );
const handleSelect = React.useCallback( const handleSelect = React.useCallback(
async (val: string) => { async (val: string) => {
setOpen(false); setOpen(false);
onChange(val); onChange(val);
}, },
[onChange] [onChange]
); );
const renderOption = React.useCallback( const renderOption = React.useCallback(
(option: Option) => { (option: Option) => {
if (option.type === "separator") { if (option.type === "separator") {
return <Separator />; return <Separator />;
} }
const isSelected = option === selectedOption; const isSelected = option === selectedOption;
return ( return (
<SelectItemWrapper <SelectItemWrapper
key={option.value} key={option.value}
onClick={() => handleSelect(option.value)} onClick={() => handleSelect(option.value)}
data-state={isSelected ? "checked" : "unchecked"} data-state={isSelected ? "checked" : "unchecked"}
>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
{isSelected && <SelectItemIndicator />}
</SelectItemWrapper>
);
},
[handleSelect, selectedOption, optionsHaveIcon]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
> >
{selectedOption ? ( <Option option={option} optionsHaveIcon={optionsHaveIcon} />
<Option {isSelected && <SelectItemIndicator />}
option={selectedOption as Item} </SelectItemWrapper>
optionsHaveIcon={optionsHaveIcon} );
/> },
) : ( [handleSelect, selectedOption, optionsHaveIcon]
<>{placeholder}</> );
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
function Label({ text, hidden }: { text: string; hidden: boolean }) { const enablePointerEvents = React.useCallback(() => {
const labelText = <LabelText>{text}</LabelText>; if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
ref={ref}
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
/>
) : (
<>{placeholder}</>
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
);
MobileSelect.displayName = "InputSelect";
function Label({
text,
hidden,
help,
}: {
text: string;
hidden: boolean;
help?: string;
}) {
const content = (
<Flex align="center" gap={2} style={{ marginBottom: "4px" }}>
<LabelText style={{ paddingBottom: 0 }}>{text}</LabelText>
{help ? (
<Tooltip content={help}>
<TooltipButton size={18}>
<QuestionMarkIcon size={18} />
</TooltipButton>
</Tooltip>
) : null}
</Flex>
);
return hidden ? ( return hidden ? (
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root> <VisuallyHidden.Root>{content}</VisuallyHidden.Root>
) : ( ) : (
labelText content
); );
} }
@@ -352,3 +384,12 @@ const IconSpacer = styled.div`
const StyledScrollable = styled(Scrollable)` const StyledScrollable = styled(Scrollable)`
max-height: 75vh; max-height: 75vh;
`; `;
const TooltipButton = styled(NudeButton)`
color: ${s("textSecondary")};
&:hover,
&[aria-expanded="true"] {
background: none !important;
}
`;

View File

@@ -1,54 +1,67 @@
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import { $Diff } from "utility-types";
import { s } from "@shared/styles"; import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types"; import { CollectionPermission } from "@shared/types";
import {
InputSelectNew,
Option as OptionNew,
} from "~/components/InputSelectNew";
import { EmptySelectValue } from "~/types"; import { EmptySelectValue } from "~/types";
import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect";
function InputSelectPermission( type Props = {
props: $Diff< shrink?: boolean;
Props, } & Pick<
{ React.ComponentProps<typeof InputSelectNew>,
options: Array<Option>; "value" | "onChange" | "disabled" | "hideLabel" | "nude" | "help"
ariaLabel: string; >;
}
>,
ref: React.RefObject<InputSelectRef>
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
return ( export const InputSelectPermission = React.forwardRef<HTMLButtonElement, Props>(
<Select (props, ref) => {
ref={ref} const { value, onChange, shrink, ...rest } = props;
label={t("Permission")} const { t } = useTranslation();
options={[
const options = React.useMemo<OptionNew[]>(
() => [
{ {
type: "item",
label: t("View only"), label: t("View only"),
value: CollectionPermission.Read, value: CollectionPermission.Read,
}, },
{ {
type: "item",
label: t("Can edit"), label: t("Can edit"),
value: CollectionPermission.ReadWrite, value: CollectionPermission.ReadWrite,
}, },
{ {
divider: true, type: "separator",
},
{
type: "item",
label: t("No access"), label: t("No access"),
value: EmptySelectValue, value: EmptySelectValue,
}, },
]} ],
ariaLabel={t("Default access")} [t]
value={value || EmptySelectValue} );
onChange={onChange}
{...rest}
/>
);
}
const Select = styled(InputSelect)` return (
<Select
ref={ref}
options={options}
value={value || EmptySelectValue}
onChange={onChange}
ariaLabel={t("Default access")}
label={t("Permission")}
$shrink={shrink}
{...rest}
/>
);
}
);
InputSelectPermission.displayName = "InputSelectPermission";
const Select = styled(InputSelectNew)<{ $shrink?: boolean }>`
color: ${s("textSecondary")}; color: ${s("textSecondary")};
${({ $shrink }) => !$shrink && "margin-bottom: 16px;"}
`; `;
export default React.forwardRef(InputSelectPermission);

View File

@@ -9,7 +9,7 @@ import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar"; import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect"; import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import InputSelectPermission from "~/components/InputSelectPermission"; import { InputSelectPermission } from "~/components/InputSelectPermission";
import Scrollable from "~/components/Scrollable"; import Scrollable from "~/components/Scrollable";
import useMaxHeight from "~/hooks/useMaxHeight"; import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
@@ -121,7 +121,6 @@ export const AccessControlList = observer(
actions={ actions={
<div style={{ marginRight: -8 }}> <div style={{ marginRight: -8 }}>
<InputSelectPermission <InputSelectPermission
style={{ margin: 0 }}
onChange={( onChange={(
value: CollectionPermission | typeof EmptySelectValue value: CollectionPermission | typeof EmptySelectValue
) => { ) => {
@@ -131,8 +130,9 @@ export const AccessControlList = observer(
}} }}
disabled={!can.update} disabled={!can.update}
value={collection?.permission} value={collection?.permission}
labelHidden hideLabel
nude nude
shrink
/> />
</div> </div>
} }

View File

@@ -30,11 +30,11 @@ const InputSelectTrigger = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Trigger>, React.ElementRef<typeof InputSelectPrimitive.Trigger>,
InputSelectTriggerProps InputSelectTriggerProps
>((props, ref) => { >((props, ref) => {
const { placeholder, children, ...buttonProps } = props; const { placeholder, children, nude, ...buttonProps } = props;
return ( return (
<InputSelectPrimitive.Trigger ref={ref} asChild> <InputSelectPrimitive.Trigger ref={ref} asChild>
<SelectButton neutral disclosure {...buttonProps}> <SelectButton neutral disclosure $nude={nude} {...buttonProps}>
<InputSelectPrimitive.Value placeholder={placeholder} /> <InputSelectPrimitive.Value placeholder={placeholder} />
</SelectButton> </SelectButton>
</InputSelectPrimitive.Trigger> </InputSelectPrimitive.Trigger>

View File

@@ -10,7 +10,7 @@ import { AttachmentPreset, CollectionPermission } from "@shared/types";
import { bytesToHumanReadable } from "@shared/utils/files"; import { bytesToHumanReadable } from "@shared/utils/files";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import InputSelectPermission from "~/components/InputSelectPermission"; import { InputSelectPermission } from "~/components/InputSelectPermission";
import LoadingIndicator from "~/components/LoadingIndicator"; import LoadingIndicator from "~/components/LoadingIndicator";
import Text from "~/components/Text"; import Text from "~/components/Text";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";

View File

@@ -5,7 +5,7 @@ import { ImportInput } from "@shared/schema";
import { CollectionPermission, IntegrationService } from "@shared/types"; import { CollectionPermission, IntegrationService } from "@shared/types";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import InputSelectPermission from "~/components/InputSelectPermission"; import { InputSelectPermission } from "~/components/InputSelectPermission";
import Text from "~/components/Text"; import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";

View File

@@ -289,11 +289,11 @@
"Flags": "Flags", "Flags": "Flags",
"Select a color": "Select a color", "Select a color": "Select a color",
"Loading": "Loading", "Loading": "Loading",
"Permission": "Permission",
"View only": "View only", "View only": "View only",
"Can edit": "Can edit", "Can edit": "Can edit",
"No access": "No access", "No access": "No access",
"Default access": "Default access", "Default access": "Default access",
"Permission": "Permission",
"Change Language": "Change Language", "Change Language": "Change Language",
"Dismiss": "Dismiss", "Dismiss": "Dismiss",
"Youre offline.": "Youre offline.", "Youre offline.": "Youre offline.",