0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): select

This commit is contained in:
wangsijie 2022-04-15 17:58:30 +08:00
parent 8d80106475
commit b22b0cf22f
No known key found for this signature in database
GPG key ID: C72642FE24F7D42B
11 changed files with 292 additions and 52 deletions

View file

@ -1,12 +1,11 @@
import React, { MouseEventHandler, ReactNode, useRef, useState } from 'react';
import ReactModal from 'react-modal';
import React, { ReactNode, useRef, useState } from 'react';
import { Props as ButtonProps } from '../Button';
import Dropdown from '../Dropdown';
import ActionMenuButton from './ActionMenuButton';
import * as styles from './index.module.scss';
import usePosition from './use-position';
export { default as ActionMenuItem } from './ActionMenuItem';
export { default as ActionMenuItem } from '../Dropdown/DropdownItem';
type Props = {
children: ReactNode;
@ -17,46 +16,26 @@ type Props = {
const ActionMenu = ({ children, buttonProps, title }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const anchorReference = useRef<HTMLDivElement>(null);
const overlayReference = useRef<HTMLDivElement>(null);
const {
position: { left, top },
mutate,
} = usePosition(anchorReference, overlayReference);
const handleClick: MouseEventHandler<HTMLButtonElement> = () => {
setIsOpen(true);
// Calc position after modal opened.
setTimeout(() => {
mutate();
});
};
return (
<div className={styles.actionMenu}>
<ActionMenuButton {...buttonProps} ref={anchorReference} onClick={handleClick} />
<ReactModal
shouldCloseOnOverlayClick
<ActionMenuButton
{...buttonProps}
ref={anchorReference}
onClick={() => {
setIsOpen(true);
}}
/>
<Dropdown
title={title}
anchorRef={anchorReference}
isOpen={isOpen}
style={{ content: { left: `${left}px`, top: `${top + 4}px` } }}
className={styles.content}
overlayClassName={styles.overlay}
onRequestClose={() => {
onClose={() => {
setIsOpen(false);
}}
>
<div ref={overlayReference}>
{title && <div className={styles.title}>{title}</div>}
<ul
className={styles.actionList}
onClick={() => {
setIsOpen(false);
}}
>
{children}
</ul>
</div>
</ReactModal>
{children}
</Dropdown>
</div>
);
};

View file

@ -1,12 +1,14 @@
@use '@/scss/underscore' as _;
.item {
padding: _.unit(2) _.unit(4);
padding: 6px _.unit(2);
border-radius: _.unit(2);
list-style: none;
font: var(--font-body-medium);
cursor: pointer;
display: flex;
align-items: center;
overflow: hidden;
&:hover {
background: var(--color-neutral-95);

View file

@ -1,7 +1,7 @@
import classNames from 'classnames';
import React from 'react';
import * as styles from './ActionMenuItem.module.scss';
import * as styles from './DropdownItem.module.scss';
type Props = {
onClick?: () => void;
@ -10,7 +10,7 @@ type Props = {
type?: 'default' | 'danger';
};
const ActionMenuItem = ({ onClick, children, icon, type = 'default' }: Props) => (
const DropdownItem = ({ onClick, children, icon, type = 'default' }: Props) => (
<li
className={classNames(styles.item, styles[type])}
onClick={() => {
@ -22,4 +22,4 @@ const ActionMenuItem = ({ onClick, children, icon, type = 'default' }: Props) =>
</li>
);
export default ActionMenuItem;
export default DropdownItem;

View file

@ -0,0 +1,33 @@
@use '@/scss/underscore' as _;
.content {
background: var(--color-layer-1);
box-shadow: var(--shadow-light-s2);
border-radius: _.unit(2);
position: absolute;
&.onTop {
box-shadow: var(--shadow-light-s2-reversed);
}
&:focus {
outline: none;
}
.title {
padding: _.unit(4) _.unit(4) 0 _.unit(4);
font: var(--font-body-medium);
color: var(--color-caption);
}
}
.overlay {
background: transparent;
position: fixed;
inset: 0;
}
ul.list {
margin: 0;
padding: _.unit(2) _.unit(1);
}

View file

@ -0,0 +1,52 @@
import classNames from 'classnames';
import React, { ReactNode, RefObject, useRef } from 'react';
import ReactModal from 'react-modal';
import * as styles from './index.module.scss';
import usePosition from './use-position';
export { default as DropdownItem } from './DropdownItem';
type Props = {
children: ReactNode;
title?: ReactNode;
isOpen: boolean;
onClose?: () => void;
anchorRef: RefObject<HTMLElement>;
isFullWidth?: boolean;
};
const Dropdown = ({ children, title, isOpen, onClose, anchorRef, isFullWidth }: Props) => {
const overlayRef = useRef<HTMLDivElement>(null);
const { position, mutate } = usePosition(anchorRef, overlayRef);
return (
<ReactModal
shouldCloseOnOverlayClick
isOpen={isOpen}
style={{
content: position
? {
left: `${position.left}px`,
top: `${position.top}px`,
width: isFullWidth ? `${position.width}px` : undefined,
}
: { visibility: 'hidden' },
}}
className={classNames(styles.content, position?.isOnTop && styles.onTop)}
overlayClassName={styles.overlay}
onRequestClose={onClose}
onAfterOpen={mutate}
>
<div ref={overlayRef}>
{title && <div className={styles.title}>{title}</div>}
<ul className={styles.list} onClick={onClose}>
{children}
</ul>
</div>
</ReactModal>
);
};
export default Dropdown;

View file

@ -0,0 +1,45 @@
import { RefObject, useCallback, useLayoutEffect, useState } from 'react';
type Position = {
left: number;
top: number;
width: number;
isOnTop?: boolean;
};
// Leave space for box-shadow effect.
const safePadding = 12;
// The distance to anchor
const distance = 4;
export default function usePosition(
anchorRef: RefObject<HTMLElement>,
overlayRef: RefObject<HTMLElement>
) {
const [position, setPosition] = useState<Position>();
const updatePosition = useCallback(() => {
if (anchorRef.current && overlayRef.current) {
const anchor = anchorRef.current.getBoundingClientRect();
const overlay = overlayRef.current.getBoundingClientRect();
const isOnTop = anchor.y + anchor.height + overlay.height > window.innerHeight - safePadding;
const isOnLeft = anchor.x + overlay.width > window.innerWidth - safePadding;
const left = isOnLeft ? anchor.x + anchor.width - overlay.width : anchor.x;
const top = isOnTop
? anchor.y - overlay.height - distance
: anchor.y + anchor.height + distance;
setPosition({ left, top, width: anchor.width, isOnTop });
}
}, [anchorRef, overlayRef]);
useLayoutEffect(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('resize', updatePosition);
};
}, [updatePosition]);
return { position, mutate: updatePosition };
}

View file

@ -0,0 +1,48 @@
@use '@/scss/underscore' as _;
.select {
background: var(--color-layer-1);
border: 1px solid var(--color-border);
border-radius: 6px;
outline: 3px solid transparent;
transition-property: outline, border;
transition-timing-function: ease-in-out;
transition-duration: 0.2s;
padding: _.unit(2) _.unit(5) _.unit(2) _.unit(3);
font: var(--font-body-medium);
cursor: pointer;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.open {
border-color: var(--color-primary);
outline-color: var(--color-focused-variant);
}
&.readOnly {
background: var(--color-inverse-on-surface);
color: var(--color-text);
border-color: var(--color-border);
cursor: default;
}
&.error {
border-color: var(--color-error);
&.open {
outline-color: var(--color-error-80);
}
}
.arrow {
position: absolute;
right: _.unit(2);
top: 6px;
svg {
fill: var(--color-icon);
}
}
}

View file

@ -0,0 +1,75 @@
import classNames from 'classnames';
import React, { ReactNode, useRef, useState } from 'react';
import { ArrowDown, ArrowUp } from '@/icons/Arrow';
import Dropdown, { DropdownItem } from '../Dropdown';
import * as styles from './index.module.scss';
type Option = {
value: string;
title: ReactNode;
};
type Props = {
value?: string;
options: Option[];
onChange?: (value: string) => void;
isReadOnly?: boolean;
hasError?: boolean;
};
const Select = ({ value, options, onChange, isReadOnly, hasError }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<HTMLInputElement>(null);
const current = options.find((option) => value && option.value === value);
const handleSelect = (value: string) => {
onChange?.(value);
setIsOpen(false);
};
return (
<>
<div
ref={anchorRef}
className={classNames(
styles.select,
isOpen && styles.open,
isReadOnly && styles.readOnly,
hasError && styles.error
)}
role="button"
onClick={() => {
if (!isReadOnly) {
setIsOpen(true);
}
}}
>
{current?.title}
<div className={styles.arrow}>{isOpen ? <ArrowUp /> : <ArrowDown />}</div>
</div>
<Dropdown
isFullWidth
anchorRef={anchorRef}
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
}}
>
{options.map(({ value, title }) => (
<DropdownItem
key={value}
onClick={() => {
handleSelect(value);
}}
>
{title}
</DropdownItem>
))}
</Dropdown>
</>
);
};
export default Select;

View file

@ -4,6 +4,7 @@ import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import FormField from '@/components/FormField';
import Select from '@/components/Select';
import Switch from '@/components/Switch';
import { SignInExperienceForm } from '../types';
@ -21,14 +22,20 @@ const SignInMethodsForm = () => {
<>
<div className={styles.title}>{t('sign_in_exp.sign_in_methods.title')}</div>
<FormField isRequired title="admin_console.sign_in_exp.sign_in_methods.primary">
{/* TODO: LOG-2191 select component */}
<select {...register('signInMethods.primary')}>
{signInMethods.map((method) => (
<option key={method} value={method}>
{t('sign_in_exp.sign_in_methods.methods', { context: method })}
</option>
))}
</select>
<Controller
name="signInMethods.primary"
control={control}
render={({ field: { value, onChange } }) => (
<Select
value={value}
options={signInMethods.map((method) => ({
value: method,
title: t('sign_in_exp.sign_in_methods.methods', { context: method }),
}))}
onChange={onChange}
/>
)}
/>
</FormField>
<FormField isRequired title="admin_console.sign_in_exp.sign_in_methods.enable_secondary">
<Switch

View file

@ -1,3 +0,0 @@
import { SignInMethods } from '@logto/schemas';
export const signInMethods: Array<keyof SignInMethods> = ['username', 'sms', 'email', 'social'];

View file

@ -137,6 +137,7 @@
--color-focused-variant: rgba(93, 52, 242, 16%); // 16% P40
--shadow-light-s1: 0 4px 8px rgba(66, 41, 159, 8%);
--shadow-light-s2: 0 4px 12px rgba(66, 41, 159, 12%);
--shadow-light-s2-reversed: 0 -4px 12px rgba(66, 41, 159, 12%);
--shadow-light-s3: 0 4px 16px rgba(66, 41, 159, 16%);
// Client specific variables
@ -286,6 +287,7 @@
--color-focused-variant: rgba(202, 190, 255, 16%); // 16% P40
--shadow-light-s1: 0 4px 8px rgba(66, 41, 159, 8%);
--shadow-light-s2: 0 4px 12px rgba(66, 41, 159, 12%);
--shadow-light-s2-reversed: 0 -4px -12px rgba(66, 41, 159, 12%);
--shadow-light-s3: 0 4px 16px rgba(66, 41, 159, 16%);
// Client specific variables