UI: Introduce new set of controls (#6825)
This commit is contained in:
parent
ac07a2242b
commit
97ba6b8031
@ -30,7 +30,7 @@ import UiLoader from './common/UiLoader';
|
||||
import AppInactive from './main/AppInactive';
|
||||
import LockScreen from './main/LockScreen.async';
|
||||
import Main from './main/Main.async';
|
||||
// import Test from './test/TestDateFormat';
|
||||
// import Test from './test/FieldTest.tsx';
|
||||
import Transition from './ui/Transition';
|
||||
|
||||
import styles from './App.module.scss';
|
||||
|
||||
@ -119,8 +119,8 @@
|
||||
.icon-view-once,
|
||||
.media-loading {
|
||||
--color-primary: var(--color-text-green);
|
||||
--color-primary-shade: var(--color-green);
|
||||
--color-primary-shade-darker: var(--color-green-darker);
|
||||
--color-primary-shade: var(--color-active);
|
||||
--color-primary-shade-darker: var(--color-active-darker);
|
||||
--color-white: var(--color-background-own);
|
||||
|
||||
.theme-dark & {
|
||||
|
||||
@ -560,7 +560,7 @@
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
|
||||
background: var(--color-green-darker);
|
||||
background: var(--color-active-darker);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
}
|
||||
|
||||
.status-open {
|
||||
color: var(--color-green);
|
||||
color: var(--color-active);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
|
||||
139
src/components/gili/layout/Control.module.scss
Normal file
139
src/components/gili/layout/Control.module.scss
Normal file
@ -0,0 +1,139 @@
|
||||
@layer ui.input {
|
||||
.control {
|
||||
display: grid;
|
||||
grid-template-areas: "input label";
|
||||
grid-template-columns: auto 1fr;
|
||||
flex-grow: 1;
|
||||
column-gap: 1rem;
|
||||
align-items: center;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "input label" "input desc";
|
||||
}
|
||||
|
||||
&:has(> .controlAfter) {
|
||||
grid-template-areas: "input label after";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "input label after" "input desc after";
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> .controlBefore) {
|
||||
grid-template-areas: "input before label";
|
||||
grid-template-columns: auto auto 1fr;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "input before label" "input before desc";
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> .controlBefore):has(> .controlAfter) {
|
||||
grid-template-areas: "input before label after";
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "input before label after" "input before desc after";
|
||||
}
|
||||
}
|
||||
|
||||
// --- inputEnd: input at end ---
|
||||
&.inputEnd {
|
||||
grid-template-areas: "label input";
|
||||
grid-template-columns: 1fr auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "label input" "desc input";
|
||||
}
|
||||
|
||||
&:has(> .controlAfter) {
|
||||
grid-template-areas: "label after input";
|
||||
grid-template-columns: 1fr auto auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "label after input" "desc after input";
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> .controlBefore) {
|
||||
grid-template-areas: "before label input";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "before label input" "before desc input";
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> .controlBefore):has(> .controlAfter) {
|
||||
grid-template-areas: "before label after input";
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "before label after input" "before desc after input";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> .controlDescription) > .controlLabel {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
&:has(> .controlDescription) > .input,
|
||||
&:has(> .controlDescription) > .spinner {
|
||||
transform: translateY(50%);
|
||||
grid-row: 1;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
:global(label) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
grid-area: input;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
--spinner-size: 1.25rem;
|
||||
|
||||
grid-area: input;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.controlLabel {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
|
||||
grid-area: label;
|
||||
align-self: center;
|
||||
|
||||
line-height: 1.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.controlDescription {
|
||||
cursor: inherit;
|
||||
|
||||
grid-area: desc;
|
||||
align-self: start;
|
||||
|
||||
margin-top: 0.125rem;
|
||||
|
||||
font-size: 0.875rem;
|
||||
line-height: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.controlBefore {
|
||||
grid-area: before;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.controlAfter {
|
||||
grid-area: after;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
150
src/components/gili/layout/Control.tsx
Normal file
150
src/components/gili/layout/Control.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import type { TeactNode } from '../../../lib/teact/teact';
|
||||
import { createContext, memo, useMemo } from '../../../lib/teact/teact';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import useContext from '../../../hooks/data/useContext';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import useUniqueId from '../../../hooks/useUniqueId';
|
||||
|
||||
import Spinner from '../../ui/Spinner';
|
||||
import { useInteractiveContext } from './Interactive';
|
||||
|
||||
import styles from './Control.module.scss';
|
||||
|
||||
export type ControlContextType = {
|
||||
id: string;
|
||||
inputClassName: string;
|
||||
};
|
||||
|
||||
export const ControlContext = createContext<ControlContextType | undefined>(undefined);
|
||||
|
||||
export function useControlContext() {
|
||||
return useContext(ControlContext);
|
||||
}
|
||||
|
||||
// #region Control
|
||||
|
||||
type ControlProps = {
|
||||
inputEnd?: boolean;
|
||||
className?: string;
|
||||
children: TeactNode;
|
||||
};
|
||||
|
||||
const Control = ({
|
||||
inputEnd,
|
||||
className,
|
||||
children,
|
||||
}: ControlProps) => {
|
||||
const uniqueId = useUniqueId();
|
||||
const lang = useLang();
|
||||
const interactive = useInteractiveContext();
|
||||
const id = `control-${uniqueId}`;
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
id,
|
||||
inputClassName: styles.input,
|
||||
}), [id]);
|
||||
|
||||
return (
|
||||
<ControlContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={buildClassName(
|
||||
styles.control,
|
||||
inputEnd && styles.inputEnd,
|
||||
className,
|
||||
)}
|
||||
dir={lang.isRtl ? 'rtl' : undefined}
|
||||
>
|
||||
{interactive?.isLoading && <Spinner className={styles.spinner} />}
|
||||
{children}
|
||||
</div>
|
||||
</ControlContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region ControlLabel / ControlDescription
|
||||
|
||||
type ControlTextProps = {
|
||||
htmlFor?: string;
|
||||
className?: string;
|
||||
children: TeactNode;
|
||||
};
|
||||
|
||||
function ControlText({
|
||||
htmlFor,
|
||||
className,
|
||||
baseClassName,
|
||||
children,
|
||||
}: ControlTextProps & { baseClassName: string }) {
|
||||
const control = useControlContext();
|
||||
const interactive = useInteractiveContext();
|
||||
const resolvedHtmlFor = htmlFor ?? control?.id;
|
||||
const shouldRenderLabel = !interactive?.isLabel && resolvedHtmlFor;
|
||||
|
||||
if (shouldRenderLabel) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={resolvedHtmlFor}
|
||||
className={buildClassName(baseClassName, className)}
|
||||
dir="auto"
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={buildClassName(baseClassName, className)}
|
||||
dir="auto"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const ControlLabel = (props: ControlTextProps) => (
|
||||
<ControlText {...props} baseClassName={styles.controlLabel} />
|
||||
);
|
||||
|
||||
const ControlDescription = (props: ControlTextProps) => (
|
||||
<ControlText {...props} baseClassName={styles.controlDescription} />
|
||||
);
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region ControlBefore / ControlAfter
|
||||
|
||||
type ControlSlotProps = {
|
||||
className?: string;
|
||||
children: TeactNode;
|
||||
};
|
||||
|
||||
const ControlBefore = ({ className, children }: ControlSlotProps) => {
|
||||
return (
|
||||
<div className={buildClassName(styles.controlBefore, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ControlAfter = ({ className, children }: ControlSlotProps) => {
|
||||
return (
|
||||
<div className={buildClassName(styles.controlAfter, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// #endregion
|
||||
|
||||
export default memo(Control);
|
||||
export {
|
||||
ControlLabel,
|
||||
ControlDescription,
|
||||
ControlBefore,
|
||||
ControlAfter,
|
||||
};
|
||||
37
src/components/gili/layout/Interactive.module.scss
Normal file
37
src/components/gili/layout/Interactive.module.scss
Normal file
@ -0,0 +1,37 @@
|
||||
@layer ui.input {
|
||||
.interactive {
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
min-height: 3rem;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius-default);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-item-active);
|
||||
}
|
||||
}
|
||||
|
||||
.nonInteractive {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
--input-disabled-opacity: 1;
|
||||
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
92
src/components/gili/layout/Interactive.tsx
Normal file
92
src/components/gili/layout/Interactive.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import type { TeactNode } from '../../../lib/teact/teact';
|
||||
import { createContext, memo, useMemo } from '../../../lib/teact/teact';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import useContext from '../../../hooks/data/useContext';
|
||||
import useClickable from '../../../hooks/useClickable';
|
||||
|
||||
import RippleEffect from '../../ui/RippleEffect';
|
||||
|
||||
import styles from './Interactive.module.scss';
|
||||
|
||||
export type InteractiveContextType = {
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
isLabel: boolean;
|
||||
};
|
||||
|
||||
export const InteractiveContext = createContext<InteractiveContextType | undefined>(undefined);
|
||||
|
||||
export function useInteractiveContext() {
|
||||
return useContext(InteractiveContext);
|
||||
}
|
||||
|
||||
type OwnProps = {
|
||||
asLabel?: boolean;
|
||||
clickable?: boolean;
|
||||
ripple?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
children: TeactNode;
|
||||
onClick?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const Interactive = ({
|
||||
asLabel,
|
||||
clickable,
|
||||
ripple,
|
||||
disabled,
|
||||
loading,
|
||||
className,
|
||||
children,
|
||||
onClick,
|
||||
}: OwnProps) => {
|
||||
const contextValue = useMemo(() => ({
|
||||
isDisabled: Boolean(disabled),
|
||||
isLoading: Boolean(loading),
|
||||
isLabel: Boolean(asLabel),
|
||||
}), [asLabel, disabled, loading]);
|
||||
|
||||
const isNonInteractive = disabled || loading;
|
||||
const clickableProps = useClickable(onClick, {
|
||||
disabled: isNonInteractive,
|
||||
withA11y: !asLabel,
|
||||
});
|
||||
|
||||
const blockClassName = buildClassName(
|
||||
styles.interactive,
|
||||
clickable && !isNonInteractive && styles.clickable,
|
||||
isNonInteractive && styles.nonInteractive,
|
||||
disabled && styles.disabled,
|
||||
className,
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{children}
|
||||
{ripple && !isNonInteractive && <RippleEffect />}
|
||||
</>
|
||||
);
|
||||
|
||||
if (asLabel) {
|
||||
return (
|
||||
<InteractiveContext.Provider value={contextValue}>
|
||||
<label className={blockClassName} {...clickableProps}>
|
||||
{content}
|
||||
</label>
|
||||
</InteractiveContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InteractiveContext.Provider value={contextValue}>
|
||||
<div className={blockClassName} {...clickableProps}>
|
||||
{content}
|
||||
</div>
|
||||
</InteractiveContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Interactive);
|
||||
63
src/components/gili/primitives/Checkbox.module.scss
Normal file
63
src/components/gili/primitives/Checkbox.module.scss
Normal file
@ -0,0 +1,63 @@
|
||||
@layer ui.input {
|
||||
.root {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin: 0;
|
||||
border: 0.125rem solid var(--color-borders-input);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
appearance: none;
|
||||
background-color: var(--color-background);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 0;
|
||||
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, background-size 0.15s ease;
|
||||
|
||||
&:checked {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-primary);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
|
||||
background-size: 0.75rem;
|
||||
}
|
||||
|
||||
&:indeterminate {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-primary);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E");
|
||||
background-size: 0.75rem;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: var(--input-disabled-opacity, 0.5);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
border-color: var(--color-error);
|
||||
|
||||
&:checked,
|
||||
&:indeterminate {
|
||||
border-color: var(--color-error);
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/components/gili/primitives/Checkbox.tsx
Normal file
78
src/components/gili/primitives/Checkbox.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { memo, useLayoutEffect, useRef } from '../../../lib/teact/teact';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import useLastCallback from '../../../hooks/useLastCallback';
|
||||
|
||||
import { useControlContext } from '../layout/Control';
|
||||
import { useInteractiveContext } from '../layout/Interactive';
|
||||
|
||||
import styles from './Checkbox.module.scss';
|
||||
|
||||
type InputProps = React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
|
||||
type OwnProps = {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
isRound?: boolean;
|
||||
indeterminate?: boolean;
|
||||
isInvalid?: boolean;
|
||||
className?: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>;
|
||||
|
||||
const Checkbox = ({
|
||||
checked,
|
||||
disabled,
|
||||
isRound,
|
||||
indeterminate,
|
||||
isInvalid,
|
||||
className,
|
||||
onChange,
|
||||
id,
|
||||
...restProps
|
||||
}: Props) => {
|
||||
const control = useControlContext();
|
||||
const interactive = useInteractiveContext();
|
||||
const ref = useRef<HTMLInputElement>();
|
||||
|
||||
const resolvedId = id ?? control?.id;
|
||||
const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!ref.current) return;
|
||||
ref.current.indeterminate = Boolean(indeterminate);
|
||||
}, [indeterminate]);
|
||||
|
||||
const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.currentTarget.checked);
|
||||
});
|
||||
|
||||
if (interactive?.isLoading) return undefined;
|
||||
|
||||
return (
|
||||
<input
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
id={resolvedId}
|
||||
checked={checked}
|
||||
disabled={isDisabled}
|
||||
className={buildClassName(
|
||||
styles.root,
|
||||
control?.inputClassName,
|
||||
isInvalid && styles.invalid,
|
||||
isRound && styles.round,
|
||||
className,
|
||||
)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Checkbox);
|
||||
34
src/components/gili/primitives/Radio.module.scss
Normal file
34
src/components/gili/primitives/Radio.module.scss
Normal file
@ -0,0 +1,34 @@
|
||||
@layer ui.input {
|
||||
.root {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin: 0;
|
||||
border: 0.125rem solid var(--color-borders-input);
|
||||
border-radius: 50%;
|
||||
|
||||
appearance: none;
|
||||
background-color: var(--color-background);
|
||||
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:checked {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-primary);
|
||||
box-shadow: inset 0 0 0 0.1875rem var(--color-background);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: var(--input-disabled-opacity, 0.5);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/components/gili/primitives/Radio.tsx
Normal file
66
src/components/gili/primitives/Radio.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { memo } from '../../../lib/teact/teact';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import useLastCallback from '../../../hooks/useLastCallback';
|
||||
|
||||
import { useControlContext } from '../layout/Control';
|
||||
import { useInteractiveContext } from '../layout/Interactive';
|
||||
|
||||
import styles from './Radio.module.scss';
|
||||
|
||||
type InputProps = React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
|
||||
type OwnProps = {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>;
|
||||
|
||||
const Radio = ({
|
||||
value,
|
||||
checked,
|
||||
disabled,
|
||||
className,
|
||||
onChange,
|
||||
id,
|
||||
...restProps
|
||||
}: Props) => {
|
||||
const control = useControlContext();
|
||||
const interactive = useInteractiveContext();
|
||||
|
||||
const resolvedId = id ?? control?.id;
|
||||
const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading;
|
||||
|
||||
const handleChange = useLastCallback(() => {
|
||||
onChange(value);
|
||||
});
|
||||
|
||||
if (interactive?.isLoading) return undefined;
|
||||
|
||||
return (
|
||||
<input
|
||||
type="radio"
|
||||
{...restProps}
|
||||
id={resolvedId}
|
||||
value={value}
|
||||
checked={checked}
|
||||
disabled={isDisabled}
|
||||
className={buildClassName(
|
||||
styles.root,
|
||||
control?.inputClassName,
|
||||
className,
|
||||
)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Radio);
|
||||
81
src/components/gili/primitives/Switch.module.scss
Normal file
81
src/components/gili/primitives/Switch.module.scss
Normal file
@ -0,0 +1,81 @@
|
||||
@layer ui.input {
|
||||
.root {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
||||
width: 1.875rem;
|
||||
height: 0.875rem;
|
||||
margin: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
appearance: none;
|
||||
background-color: var(--color-borders-input);
|
||||
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
||||
transform: translateX(-0.125rem);
|
||||
|
||||
display: block;
|
||||
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 0.125rem solid var(--color-borders-input);
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--color-background);
|
||||
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-primary);
|
||||
|
||||
&::before {
|
||||
transform: translateX(0.75rem);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: var(--input-disabled-opacity, 0.5);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.permissionColors {
|
||||
background-color: var(--color-error);
|
||||
|
||||
&::before {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
&:checked {
|
||||
border-color: var(--color-green);
|
||||
background-color: var(--color-green);
|
||||
|
||||
&::before {
|
||||
border-color: var(--color-green);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: var(--color-error);
|
||||
}
|
||||
|
||||
&:checked:focus-visible {
|
||||
outline-color: var(--color-green);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/components/gili/primitives/Switch.tsx
Normal file
67
src/components/gili/primitives/Switch.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { memo } from '../../../lib/teact/teact';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import useLastCallback from '../../../hooks/useLastCallback';
|
||||
|
||||
import { useControlContext } from '../layout/Control';
|
||||
import { useInteractiveContext } from '../layout/Interactive';
|
||||
|
||||
import styles from './Switch.module.scss';
|
||||
|
||||
type InputProps = React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
|
||||
type OwnProps = {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
withPermissionColors?: boolean;
|
||||
className?: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>;
|
||||
|
||||
const Switch = ({
|
||||
checked,
|
||||
disabled,
|
||||
withPermissionColors,
|
||||
className,
|
||||
onChange,
|
||||
id,
|
||||
...restProps
|
||||
}: Props) => {
|
||||
const control = useControlContext();
|
||||
const interactive = useInteractiveContext();
|
||||
|
||||
const resolvedId = id ?? control?.id;
|
||||
const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading;
|
||||
|
||||
const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.currentTarget.checked);
|
||||
});
|
||||
|
||||
if (interactive?.isLoading) return undefined;
|
||||
|
||||
return (
|
||||
<input
|
||||
{...restProps}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id={resolvedId}
|
||||
checked={checked}
|
||||
disabled={isDisabled}
|
||||
className={buildClassName(
|
||||
styles.root,
|
||||
withPermissionColors && styles.permissionColors,
|
||||
control?.inputClassName,
|
||||
className,
|
||||
)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Switch);
|
||||
56
src/components/gili/templates/CheckboxField.tsx
Normal file
56
src/components/gili/templates/CheckboxField.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { memo } from '../../../lib/teact/teact';
|
||||
|
||||
import Control, {
|
||||
ControlDescription,
|
||||
ControlLabel,
|
||||
} from '../layout/Control';
|
||||
import Interactive from '../layout/Interactive';
|
||||
import Checkbox from '../primitives/Checkbox';
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof Checkbox>, 'className' | 'disabled'> & {
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
controlClassName?: string;
|
||||
labelClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
ripple?: boolean;
|
||||
};
|
||||
|
||||
const CheckboxField = ({
|
||||
label,
|
||||
description,
|
||||
disabled,
|
||||
loading,
|
||||
className,
|
||||
controlClassName,
|
||||
labelClassName,
|
||||
descriptionClassName,
|
||||
ripple,
|
||||
...checkboxProps
|
||||
}: Props) => {
|
||||
return (
|
||||
<Interactive
|
||||
asLabel
|
||||
clickable
|
||||
ripple={ripple}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
className={className}
|
||||
>
|
||||
<Control className={controlClassName}>
|
||||
<Checkbox {...checkboxProps} />
|
||||
<ControlLabel className={labelClassName}>{label}</ControlLabel>
|
||||
{description !== undefined ? (
|
||||
<ControlDescription className={descriptionClassName}>
|
||||
{description}
|
||||
</ControlDescription>
|
||||
) : undefined}
|
||||
</Control>
|
||||
</Interactive>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CheckboxField);
|
||||
56
src/components/gili/templates/SwitchField.tsx
Normal file
56
src/components/gili/templates/SwitchField.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { memo } from '../../../lib/teact/teact';
|
||||
|
||||
import Control, {
|
||||
ControlDescription,
|
||||
ControlLabel,
|
||||
} from '../layout/Control';
|
||||
import Interactive from '../layout/Interactive';
|
||||
import Switch from '../primitives/Switch';
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof Switch>, 'className' | 'disabled'> & {
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
controlClassName?: string;
|
||||
labelClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
ripple?: boolean;
|
||||
};
|
||||
|
||||
const SwitchField = ({
|
||||
label,
|
||||
description,
|
||||
disabled,
|
||||
loading,
|
||||
className,
|
||||
controlClassName,
|
||||
labelClassName,
|
||||
descriptionClassName,
|
||||
ripple,
|
||||
...switchProps
|
||||
}: Props) => {
|
||||
return (
|
||||
<Interactive
|
||||
asLabel
|
||||
clickable
|
||||
ripple={ripple}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
className={className}
|
||||
>
|
||||
<Control inputEnd className={controlClassName}>
|
||||
<Switch {...switchProps} />
|
||||
<ControlLabel className={labelClassName}>{label}</ControlLabel>
|
||||
{description !== undefined ? (
|
||||
<ControlDescription className={descriptionClassName}>
|
||||
{description}
|
||||
</ControlDescription>
|
||||
) : undefined}
|
||||
</Control>
|
||||
</Interactive>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SwitchField);
|
||||
@ -54,7 +54,7 @@
|
||||
.unread,
|
||||
.unopened {
|
||||
color: var(--color-white);
|
||||
background: var(--color-green);
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
.unopened {
|
||||
|
||||
@ -423,14 +423,6 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
|
||||
setSelectedChannelIds(newSelectedIds);
|
||||
});
|
||||
|
||||
const handleShouldShowWinnersChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setShouldShowWinners(e.target.checked);
|
||||
});
|
||||
|
||||
const handleShouldShowPrizesChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setShouldShowPrizes(e.target.checked);
|
||||
});
|
||||
|
||||
const onClickActionHandler = useLastCallback(() => {
|
||||
openCountryPickerModal();
|
||||
});
|
||||
@ -600,7 +592,7 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
|
||||
<Switcher
|
||||
label={lang('BoostingGiveawayAdditionalPrizes')}
|
||||
checked={shouldShowPrizes}
|
||||
onChange={handleShouldShowPrizesChange}
|
||||
onCheck={setShouldShowPrizes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -650,7 +642,7 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
|
||||
<Switcher
|
||||
label={lang('BoostingGiveawayAdditionalPrizes')}
|
||||
checked={shouldShowWinners}
|
||||
onChange={handleShouldShowWinnersChange}
|
||||
onCheck={setShouldShowWinners}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
color: white;
|
||||
text-align: center;
|
||||
|
||||
background-color: var(--color-green);
|
||||
background-color: var(--color-active);
|
||||
|
||||
@media (max-width: 600px) {
|
||||
top: -0.6875rem;
|
||||
|
||||
@ -197,7 +197,7 @@
|
||||
--active-color: var(--color-reply-own-active);
|
||||
--max-width: 30rem;
|
||||
--accent-color: var(--color-accent-own);
|
||||
--accent-shade-color: var(--color-green);
|
||||
--accent-shade-color: var(--color-active);
|
||||
--secondary-color: var(--color-accent-own);
|
||||
--color-code: var(--color-code-own);
|
||||
--color-code-bg: var(--color-code-own-bg);
|
||||
@ -411,13 +411,13 @@
|
||||
|
||||
&.is-selected {
|
||||
.message-select-control {
|
||||
background: var(--color-green);
|
||||
background: var(--color-active);
|
||||
|
||||
&.group-select {
|
||||
background: transparent;
|
||||
|
||||
&.is-selected {
|
||||
background: var(--color-green);
|
||||
background: var(--color-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -425,7 +425,7 @@
|
||||
|
||||
.File.file-is-selected {
|
||||
.message-select-control {
|
||||
background: var(--color-green);
|
||||
background: var(--color-active);
|
||||
}
|
||||
}
|
||||
|
||||
@ -488,7 +488,7 @@
|
||||
|
||||
&.is-selected {
|
||||
.message-select-control {
|
||||
background: var(--color-green);
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
img,
|
||||
@ -501,7 +501,7 @@
|
||||
&.is-selected,
|
||||
&.is-forwarding {
|
||||
.message-select-control {
|
||||
background: var(--color-green);
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
.Menu .bubble {
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
.player-button {
|
||||
--color-text-secondary: var(--color-primary);
|
||||
--color-text-secondary-rgb: var(--color-primary-shade-rgb);
|
||||
--color-primary-shade: var(--color-green);
|
||||
--color-primary-shade: var(--color-active);
|
||||
--color-white: var(--color-background-own);
|
||||
|
||||
margin: 0.125rem;
|
||||
|
||||
@ -279,7 +279,7 @@ function GiftComposer({
|
||||
</span>
|
||||
<Switcher
|
||||
checked={shouldPayByStars}
|
||||
onChange={toggleShouldPayByStars}
|
||||
inactive
|
||||
label={lang('GiftPremiumPayWithStarsAcc')}
|
||||
/>
|
||||
</ListItem>
|
||||
@ -319,7 +319,7 @@ function GiftComposer({
|
||||
</span>
|
||||
<Switcher
|
||||
checked={shouldPayForUpgrade}
|
||||
onChange={handleShouldPayForUpgradeChange}
|
||||
inactive
|
||||
label={lang('GiftMakeUniqueAcc')}
|
||||
/>
|
||||
</ListItem>
|
||||
@ -358,7 +358,7 @@ function GiftComposer({
|
||||
<span>{lang('GiftHideMyName')}</span>
|
||||
<Switcher
|
||||
checked={shouldHideName}
|
||||
onChange={handleShouldHideNameChange}
|
||||
inactive
|
||||
label={lang('GiftHideMyName')}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
501
src/components/test/FieldTest.tsx
Normal file
501
src/components/test/FieldTest.tsx
Normal file
@ -0,0 +1,501 @@
|
||||
/* eslint-disable @stylistic/max-len */
|
||||
import { useState } from '../../lib/teact/teact';
|
||||
|
||||
import buildStyle from '../../util/buildStyle';
|
||||
|
||||
import Control, {
|
||||
ControlAfter,
|
||||
ControlBefore,
|
||||
ControlDescription,
|
||||
ControlLabel,
|
||||
} from '../gili/layout/Control';
|
||||
import Interactive from '../gili/layout/Interactive';
|
||||
import Checkbox from '../gili/primitives/Checkbox';
|
||||
import Radio from '../gili/primitives/Radio';
|
||||
import Switch from '../gili/primitives/Switch';
|
||||
import CheckboxField from '../gili/templates/CheckboxField';
|
||||
import SwitchField from '../gili/templates/SwitchField';
|
||||
|
||||
function Section({ title, children, noBorder }: { title: string; children: any; noBorder?: boolean }) {
|
||||
return (
|
||||
<div style="margin-bottom: 2rem; break-inside: avoid">
|
||||
<h3 style="margin: 0 0 0.5rem; font-size: 0.875rem; color: #888; text-transform: uppercase; letter-spacing: 0.05em">
|
||||
{title}
|
||||
</h3>
|
||||
<div
|
||||
style={buildStyle(
|
||||
!noBorder && 'border: 1px solid var(--color-borders-input); border-radius: 0.75rem',
|
||||
'overflow: hidden',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FieldTest = () => {
|
||||
const [check1, setCheck1] = useState(false);
|
||||
const [check2, setCheck2] = useState(true);
|
||||
const [check3, setCheck3] = useState(false);
|
||||
const [check4, setCheck4] = useState(true);
|
||||
const [check5, setCheck5] = useState(false);
|
||||
const [check6, setCheck6] = useState(false);
|
||||
const [check7, setCheck7] = useState(true);
|
||||
const [check8, setCheck8] = useState(false);
|
||||
const [check9, setCheck9] = useState(false);
|
||||
const [checkRound1, setCheckRound1] = useState(false);
|
||||
const [checkRound2, setCheckRound2] = useState(true);
|
||||
const [itemA, setItemA] = useState(true);
|
||||
const [itemB, setItemB] = useState(false);
|
||||
const [itemC, setItemC] = useState(true);
|
||||
const [templateCheckbox, setTemplateCheckbox] = useState(true);
|
||||
const [permissionSwitch, setPermissionSwitch] = useState(false);
|
||||
const [templateSwitch, setTemplateSwitch] = useState(false);
|
||||
|
||||
const allChecked = itemA && itemB && itemC;
|
||||
const noneChecked = !itemA && !itemB && !itemC;
|
||||
const isIndeterminate = !allChecked && !noneChecked;
|
||||
|
||||
const [radioValue, setRadioValue] = useState('a');
|
||||
|
||||
const [switch1, setSwitch1] = useState(false);
|
||||
const [switch2, setSwitch2] = useState(true);
|
||||
const [switch3, setSwitch3] = useState(false);
|
||||
const [switch4, setSwitch4] = useState(true);
|
||||
|
||||
return (
|
||||
<div style="overflow-y: auto; height: 100vh">
|
||||
<div style="columns: 28rem 2; column-gap: 2rem; padding: 2rem">
|
||||
<h2 style="margin: 0 0 1.5rem; column-span: all">Control Component Test</h2>
|
||||
|
||||
{/* Bare primitives */}
|
||||
<Section title="Bare Primitives (no Control)">
|
||||
<div style="display: flex; gap: 1rem; padding: 1rem; align-items: center">
|
||||
<Checkbox checked={check1} onChange={setCheck1} />
|
||||
<Checkbox checked={check2} onChange={setCheck2} />
|
||||
<Checkbox checked={false} isInvalid onChange={setCheck1} />
|
||||
<Checkbox checked={false} disabled onChange={setCheck1} />
|
||||
<Checkbox checked onChange={setCheck1} isRound />
|
||||
<Checkbox checked={false} onChange={setCheck1} isRound />
|
||||
<Radio value="x" checked onChange={setRadioValue} name="bare" />
|
||||
<Radio value="y" checked={false} onChange={setRadioValue} name="bare" />
|
||||
<Radio value="z" checked={false} disabled onChange={setRadioValue} name="bare" />
|
||||
<Switch checked={switch1} onChange={setSwitch1} />
|
||||
<Switch checked={switch2} onChange={setSwitch2} />
|
||||
<Switch checked={permissionSwitch} withPermissionColors onChange={setPermissionSwitch} />
|
||||
<Switch checked withPermissionColors onChange={setSwitch1} />
|
||||
<Switch checked={false} disabled onChange={setSwitch1} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Templates">
|
||||
<CheckboxField
|
||||
checked={templateCheckbox}
|
||||
onChange={setTemplateCheckbox}
|
||||
label="Archive muted chats"
|
||||
description="New muted chats will skip the main list"
|
||||
/>
|
||||
<CheckboxField
|
||||
checked={false}
|
||||
isInvalid
|
||||
onChange={setCheck1}
|
||||
label="Delete messages"
|
||||
description="This action is restricted for your role"
|
||||
/>
|
||||
<SwitchField
|
||||
checked={templateSwitch}
|
||||
onChange={setTemplateSwitch}
|
||||
label="Translate messages"
|
||||
description="Offer inline translation when a different language is detected"
|
||||
/>
|
||||
<SwitchField
|
||||
checked={permissionSwitch}
|
||||
withPermissionColors
|
||||
onChange={setPermissionSwitch}
|
||||
label="Pin messages"
|
||||
description={permissionSwitch ? 'Allowed for this role' : 'Denied for this role'}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Interactive + Control with ControlLabel (auto-linked via context) */}
|
||||
<Section title="Interactive + Control + ControlLabel">
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={check3} onChange={setCheck3} />
|
||||
<ControlLabel>Accept terms and conditions</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={check4} onChange={setCheck4} />
|
||||
<ControlLabel>Remember me on this device</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* Radio in Control */}
|
||||
<Section title="Radio in Control">
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={check1} onChange={setCheck1} />
|
||||
<ControlLabel>Click description to toggle</ControlLabel>
|
||||
<ControlDescription>Clicking anywhere toggles the checkbox</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Radio value="a" checked={radioValue === 'a'} onChange={setRadioValue} name="aslabel" />
|
||||
<ControlLabel>Option A</ControlLabel>
|
||||
<ControlDescription>Click anywhere on this field</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Radio value="b" checked={radioValue === 'b'} onChange={setRadioValue} name="aslabel" />
|
||||
<ControlLabel>Option B</ControlLabel>
|
||||
<ControlDescription>Including the description text</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* Control + Label + Description */}
|
||||
<Section title="Control + Label + Description">
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={check5} onChange={setCheck5} />
|
||||
<ControlLabel>Keep signed in</ControlLabel>
|
||||
<ControlDescription>Your session will persist across browser restarts and device reboots</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={check6} onChange={setCheck6} />
|
||||
<ControlLabel>Enable two-factor authentication</ControlLabel>
|
||||
<ControlDescription>Adds an extra layer of security</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* Round checkboxes */}
|
||||
<Section title="Round Checkboxes">
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={checkRound1} onChange={setCheckRound1} isRound />
|
||||
<ControlLabel>Round checkbox</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={checkRound2} onChange={setCheckRound2} isRound />
|
||||
<ControlLabel>Round pre-checked</ControlLabel>
|
||||
<ControlDescription>This one started checked</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* Indeterminate */}
|
||||
<Section title="Indeterminate State">
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(v) => {
|
||||
setItemA(v);
|
||||
setItemB(v);
|
||||
setItemC(v);
|
||||
}}
|
||||
/>
|
||||
<ControlLabel>Select all</ControlLabel>
|
||||
<ControlDescription>
|
||||
{allChecked ? 'All selected' : noneChecked ? 'None selected' : 'Some selected'}
|
||||
</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={itemA} onChange={setItemA} />
|
||||
<ControlLabel>Item A</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={itemB} onChange={setItemB} />
|
||||
<ControlLabel>Item B</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={itemC} onChange={setItemC} />
|
||||
<ControlLabel>Item C</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* Radio group */}
|
||||
<Section title="Radio Group">
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Radio value="a" checked={radioValue === 'a'} onChange={setRadioValue} name="demo" />
|
||||
<ControlLabel>Default spacing</ControlLabel>
|
||||
<ControlDescription>Standard spacing for most use cases</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Radio value="b" checked={radioValue === 'b'} onChange={setRadioValue} name="demo" />
|
||||
<ControlLabel>Comfortable</ControlLabel>
|
||||
<ControlDescription>More space between elements</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Radio value="c" checked={radioValue === 'c'} onChange={setRadioValue} name="demo" />
|
||||
<ControlLabel>Compact</ControlLabel>
|
||||
<ControlDescription>Minimal spacing for dense layouts</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* Switch */}
|
||||
<Section title="Switch">
|
||||
<Interactive asLabel clickable>
|
||||
<Control inputEnd>
|
||||
<Switch checked={switch1} onChange={setSwitch1} />
|
||||
<ControlLabel>Only Accept TON</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control inputEnd>
|
||||
<Switch checked={switch2} onChange={setSwitch2} />
|
||||
<ControlLabel>Enable notifications</ControlLabel>
|
||||
<ControlDescription>Receive push notifications for new messages</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control inputEnd>
|
||||
<Switch checked={switch3} onChange={setSwitch3} />
|
||||
<ControlLabel>Auto-download media</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control inputEnd>
|
||||
<Switch checked={switch4} onChange={setSwitch4} />
|
||||
<ControlLabel>
|
||||
This is a long label that wraps to multiple lines to verify vertical centering with the switch
|
||||
</ControlLabel>
|
||||
<ControlDescription>And a description underneath for good measure</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable disabled>
|
||||
<Control inputEnd>
|
||||
<Switch checked onChange={setSwitch1} />
|
||||
<ControlLabel>Disabled switch (on)</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable loading>
|
||||
<Control inputEnd>
|
||||
<Switch checked={false} onChange={setSwitch1} />
|
||||
<ControlLabel>Loading switch</ControlLabel>
|
||||
<ControlDescription>Spinner replaces the switch</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* Disabled */}
|
||||
<Section title="Disabled">
|
||||
<Interactive asLabel clickable disabled>
|
||||
<Control>
|
||||
<Checkbox checked={false} onChange={setCheck1} />
|
||||
<ControlLabel>Disabled unchecked</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable disabled>
|
||||
<Control>
|
||||
<Checkbox checked onChange={setCheck1} />
|
||||
<ControlLabel>Disabled checked</ControlLabel>
|
||||
<ControlDescription>This option is currently unavailable</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable disabled>
|
||||
<Control>
|
||||
<Radio value="dis" checked onChange={setRadioValue} name="disabled" />
|
||||
<ControlLabel>Disabled radio</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* inputEnd */}
|
||||
<Section title="Input at End (inputEnd)">
|
||||
<Interactive asLabel clickable>
|
||||
<Control inputEnd>
|
||||
<Checkbox checked={check7} onChange={setCheck7} />
|
||||
<ControlLabel>Enable notifications</ControlLabel>
|
||||
<ControlDescription>Receive alerts for new messages</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control inputEnd>
|
||||
<Checkbox checked={check8} onChange={setCheck8} />
|
||||
<ControlLabel>Auto-download media</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable disabled>
|
||||
<Control inputEnd>
|
||||
<Checkbox checked onChange={setCheck1} />
|
||||
<ControlLabel>Disabled at end</ControlLabel>
|
||||
<ControlDescription>Cannot toggle this</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* ControlAfter */}
|
||||
<Section title="ControlAfter Helper">
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={check9} onChange={setCheck9} />
|
||||
<ControlLabel>Notifications</ControlLabel>
|
||||
<ControlDescription>Get notified about updates</ControlDescription>
|
||||
<ControlAfter>
|
||||
<span style="display: inline-flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; border-radius: 50%; background: var(--color-primary); color: white; font-size: 0.75rem">
|
||||
3
|
||||
</span>
|
||||
</ControlAfter>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control inputEnd>
|
||||
<Checkbox checked={check7} onChange={setCheck7} />
|
||||
<ControlLabel>inputEnd + after</ControlLabel>
|
||||
<ControlAfter>
|
||||
<span style="font-size: 1.25rem">⚡</span>
|
||||
</ControlAfter>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* ControlBefore */}
|
||||
<Section title="ControlBefore Helper">
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Radio value="a" checked={radioValue === 'a'} onChange={setRadioValue} name="before" />
|
||||
<ControlBefore>
|
||||
<div style="width: 2rem; height: 2rem; border-radius: 50%; background: #3390ec; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.75rem">
|
||||
AB
|
||||
</div>
|
||||
</ControlBefore>
|
||||
<ControlLabel>Alice Brown</ControlLabel>
|
||||
<ControlDescription>Online</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Radio value="b" checked={radioValue === 'b'} onChange={setRadioValue} name="before" />
|
||||
<ControlBefore>
|
||||
<div style="width: 2rem; height: 2rem; border-radius: 50%; background: #e06c75; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.75rem">
|
||||
CD
|
||||
</div>
|
||||
</ControlBefore>
|
||||
<ControlLabel>Charlie Davis</ControlLabel>
|
||||
<ControlDescription>Last seen recently</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* ControlBefore + ControlAfter */}
|
||||
<Section title="ControlBefore + ControlAfter Combined">
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={check4} onChange={setCheck4} />
|
||||
<ControlBefore>
|
||||
<div style="width: 2rem; height: 2rem; border-radius: 50%; background: #61afef; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.75rem">
|
||||
JD
|
||||
</div>
|
||||
</ControlBefore>
|
||||
<ControlLabel>John Doe</ControlLabel>
|
||||
<ControlDescription>Admin</ControlDescription>
|
||||
<ControlAfter>
|
||||
<span style="font-size: 0.75rem; color: var(--color-text-secondary)">Owner</span>
|
||||
</ControlAfter>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* Long content */}
|
||||
<Section title="Long Content (centering test)">
|
||||
<Interactive asLabel clickable>
|
||||
<Control>
|
||||
<Checkbox checked={check5} onChange={setCheck5} />
|
||||
<ControlLabel>
|
||||
This is a very long label text that should wrap to multiple lines to verify the checkbox stays vertically centered
|
||||
</ControlLabel>
|
||||
<ControlDescription>
|
||||
And this description is also quite long to demonstrate that the checkbox centers between the label and description areas correctly even with significant text content
|
||||
</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable>
|
||||
<Control inputEnd>
|
||||
<Checkbox checked={check6} onChange={setCheck6} />
|
||||
<ControlLabel>
|
||||
Another long label to test inputEnd centering with wrapping text content
|
||||
</ControlLabel>
|
||||
<ControlDescription>
|
||||
Description wrapping for the inputEnd variant showing the checkbox on the right side
|
||||
</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* Loading */}
|
||||
<Section title="Loading State">
|
||||
<Interactive asLabel clickable loading>
|
||||
<Control>
|
||||
<Checkbox checked={false} onChange={setCheck1} />
|
||||
<ControlLabel>Loading (label only)</ControlLabel>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable loading>
|
||||
<Control>
|
||||
<Checkbox checked onChange={setCheck1} />
|
||||
<ControlLabel>Loading with description</ControlLabel>
|
||||
<ControlDescription>Spinner replaces the checkbox</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable loading>
|
||||
<Control inputEnd>
|
||||
<Checkbox checked={false} onChange={setCheck1} />
|
||||
<ControlLabel>Loading at end</ControlLabel>
|
||||
<ControlDescription>inputEnd + loading</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
<Interactive asLabel clickable loading>
|
||||
<Control>
|
||||
<Radio value="x" checked onChange={setRadioValue} name="loading" />
|
||||
<ControlLabel>Loading radio</ControlLabel>
|
||||
<ControlDescription>Spinner replaces the radio button</ControlDescription>
|
||||
</Control>
|
||||
</Interactive>
|
||||
</Section>
|
||||
|
||||
{/* Control without Interactive */}
|
||||
<Section noBorder title="Control without Interactive (no padding/hover)">
|
||||
<div style="padding: 0.5rem 1rem">
|
||||
<Control>
|
||||
<Checkbox checked={check2} onChange={setCheck2} />
|
||||
<ControlLabel>Bare field, custom container</ControlLabel>
|
||||
<ControlDescription>
|
||||
Control only handles grid layout
|
||||
<br />
|
||||
Description is clickable too
|
||||
</ControlDescription>
|
||||
</Control>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldTest;
|
||||
@ -13,6 +13,8 @@
|
||||
}
|
||||
|
||||
.ripple-container {
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, useState } from '../../lib/teact/teact';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from '../../lib/teact/teact';
|
||||
|
||||
import { debounce } from '../../util/schedulers';
|
||||
|
||||
@ -16,6 +16,7 @@ const ANIMATION_DURATION_MS = 700;
|
||||
|
||||
const RippleEffect = () => {
|
||||
const [ripples, setRipples] = useState<Ripple[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement>();
|
||||
|
||||
const cleanUpDebounced = useMemo(() => {
|
||||
return debounce(() => {
|
||||
@ -23,17 +24,17 @@ const RippleEffect = () => {
|
||||
}, ANIMATION_DURATION_MS, false);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useLastCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const handleMouseDown = useLastCallback((e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const container = e.currentTarget;
|
||||
const position = container.getBoundingClientRect();
|
||||
const rippleSize = container.offsetWidth / 2;
|
||||
const parent = containerRef.current?.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
setRipples([
|
||||
...ripples,
|
||||
const position = parent.getBoundingClientRect();
|
||||
const rippleSize = parent.offsetWidth / 2;
|
||||
|
||||
setRipples((prev) => [
|
||||
...prev,
|
||||
{
|
||||
x: e.clientX - position.x - (rippleSize / 2),
|
||||
y: e.clientY - position.y - (rippleSize / 2),
|
||||
@ -44,8 +45,16 @@ const RippleEffect = () => {
|
||||
cleanUpDebounced();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const parent = containerRef.current?.parentElement;
|
||||
if (!parent) return undefined;
|
||||
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
return () => parent.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [handleMouseDown]);
|
||||
|
||||
return (
|
||||
<div className="ripple-container" onMouseDown={handleMouseDown}>
|
||||
<div ref={containerRef} className="ripple-container">
|
||||
{ripples.map(({ x, y, size }) => (
|
||||
<div
|
||||
className="ripple-wave"
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import type { FC } from '../../lib/teact/teact';
|
||||
import { memo, useCallback } from '../../lib/teact/teact';
|
||||
import { memo } from '../../lib/teact/teact';
|
||||
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
|
||||
import useLastCallback from '../../hooks/useLastCallback';
|
||||
|
||||
import './Switcher.scss';
|
||||
|
||||
type OwnProps = {
|
||||
@ -15,11 +15,10 @@ type OwnProps = {
|
||||
disabled?: boolean;
|
||||
inactive?: boolean;
|
||||
noAnimation?: boolean;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
onCheck?: (isChecked: boolean) => void;
|
||||
};
|
||||
|
||||
const Switcher: FC<OwnProps> = ({
|
||||
const Switcher = ({
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
@ -28,18 +27,11 @@ const Switcher: FC<OwnProps> = ({
|
||||
disabled,
|
||||
inactive,
|
||||
noAnimation,
|
||||
onChange,
|
||||
onCheck,
|
||||
}) => {
|
||||
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
|
||||
if (onCheck) {
|
||||
onCheck(e.currentTarget.checked);
|
||||
}
|
||||
}, [onChange, onCheck]);
|
||||
}: OwnProps) => {
|
||||
const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onCheck?.(e.currentTarget.checked);
|
||||
});
|
||||
|
||||
const className = buildClassName(
|
||||
'Switcher',
|
||||
|
||||
77
src/hooks/useClickable.ts
Normal file
77
src/hooks/useClickable.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { useRef } from '../lib/teact/teact';
|
||||
|
||||
import useLastCallback from './useLastCallback';
|
||||
|
||||
type UseClickableOptions = {
|
||||
disabled?: boolean;
|
||||
role?: React.AriaRole;
|
||||
withA11y?: boolean;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
type ClickableProps<T extends HTMLElement> = Partial<Pick<
|
||||
React.HTMLAttributes<T>,
|
||||
'onClick' | 'onKeyDown' | 'onKeyUp' | 'role' | 'tabIndex' | 'aria-disabled'
|
||||
>>;
|
||||
|
||||
// WAI-ARIA keyboard activation patterns per role
|
||||
const ROLES_WITH_ENTER_ACTIVATION = new Set<React.AriaRole>([
|
||||
'button', 'link', 'menuitem', 'switch', 'tab', 'treeitem',
|
||||
]);
|
||||
|
||||
const ROLES_WITH_SPACE_ACTIVATION = new Set<React.AriaRole>([
|
||||
'button', 'switch', 'checkbox', 'radio', 'option', 'tab', 'treeitem',
|
||||
]);
|
||||
|
||||
export default function useClickable<T extends HTMLElement>(
|
||||
onPress?: (e: React.MouseEvent<T>) => void,
|
||||
{
|
||||
disabled,
|
||||
role = 'button',
|
||||
withA11y = true,
|
||||
tabIndex = 0,
|
||||
}: UseClickableOptions = {},
|
||||
): ClickableProps<T> {
|
||||
const shouldHandleSyntheticClickRef = useRef(false);
|
||||
const withListeners = Boolean(onPress) && !disabled;
|
||||
|
||||
const handlePress = useLastCallback((e: React.MouseEvent<T>) => {
|
||||
const nativeEvent = e.nativeEvent as (MouseEvent & { pointerId?: number }) | undefined;
|
||||
const isSyntheticClick = e.detail === 0 || nativeEvent?.pointerId === -1; // Some mouse clicks produce two events
|
||||
|
||||
if (isSyntheticClick && !shouldHandleSyntheticClickRef.current) return;
|
||||
|
||||
shouldHandleSyntheticClickRef.current = false;
|
||||
onPress?.(e);
|
||||
});
|
||||
|
||||
const handleKeyDown = useLastCallback((e: React.KeyboardEvent<T>) => {
|
||||
if (e.key === 'Enter' && ROLES_WITH_ENTER_ACTIVATION.has(role)) {
|
||||
e.preventDefault();
|
||||
shouldHandleSyntheticClickRef.current = true;
|
||||
e.currentTarget.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === ' ' && ROLES_WITH_SPACE_ACTIVATION.has(role)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
const handleKeyUp = useLastCallback((e: React.KeyboardEvent<T>) => {
|
||||
if (e.key !== ' ' || !ROLES_WITH_SPACE_ACTIVATION.has(role)) return;
|
||||
|
||||
e.preventDefault();
|
||||
shouldHandleSyntheticClickRef.current = true;
|
||||
e.currentTarget.click();
|
||||
});
|
||||
|
||||
return {
|
||||
onClick: withListeners ? handlePress : undefined,
|
||||
onKeyDown: withListeners ? handleKeyDown : undefined,
|
||||
onKeyUp: withListeners ? handleKeyUp : undefined,
|
||||
role: onPress && withA11y ? role : undefined,
|
||||
tabIndex: onPress && withA11y ? (disabled ? -1 : tabIndex) : undefined,
|
||||
'aria-disabled': onPress && withA11y && disabled ? true : undefined,
|
||||
};
|
||||
}
|
||||
@ -54,7 +54,7 @@
|
||||
<style>
|
||||
@layer reset, variables, ui, components;
|
||||
@layer ui {
|
||||
@layer tablist, spinner, button;
|
||||
@layer tablist, spinner, button, input;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@ -115,14 +115,18 @@
|
||||
--color-primary-opacity: rgba(var(--color-primary), 0.2);
|
||||
--color-primary-opacity-hover: rgba(var(--color-primary), 0.25);
|
||||
--color-primary-tint: rgba(var(--color-primary), 0.1);
|
||||
--color-green: #{$color-green};
|
||||
--color-green-darker: #{color.mix($color-green, $color-black, 84%)};
|
||||
--color-active: #{$color-green};
|
||||
--color-active-darker: #{color.mix($color-green, $color-black, 84%)};
|
||||
--color-success: #{$color-green};
|
||||
|
||||
--accent-color: var(--color-primary);
|
||||
--accent-background-color: var(--color-primary-tint);
|
||||
--accent-background-active-color: var(--color-primary-opacity-hover);
|
||||
|
||||
--color-green: #{$color-green};
|
||||
--color-green-darker: #{color.mix($color-green, $color-black, 84%)};
|
||||
--color-green-rgb: #{toRGB($color-green)};
|
||||
|
||||
--color-error: #{$color-error};
|
||||
--color-error-shade: #{color.mix($color-error, $color-black, 92%)};
|
||||
--color-error-rgb: #{toRGB($color-error)};
|
||||
|
||||
@ -482,7 +482,7 @@ body:not(.is-ios) {
|
||||
--color-list-icon: rgb(112, 117, 121);
|
||||
--color-default-shadow: rgb(16, 16, 16, 0.612);
|
||||
--color-light-shadow: rgb(0, 0, 0, 0.251);
|
||||
--color-green: rgb(135, 116, 225);
|
||||
--color-active: rgb(135, 116, 225);
|
||||
--color-success: rgb(0, 199, 62);
|
||||
--color-text-meta-colored: rgb(131, 120, 219);
|
||||
--color-reply-hover: rgb(39, 39, 39);
|
||||
|
||||
@ -34,8 +34,10 @@
|
||||
"--color-list-icon": ["#ABAFB1", "#A2A2A2"],
|
||||
"--color-default-shadow": ["#72727240", "#1010109c"],
|
||||
"--color-light-shadow": ["#7272722B", "#00000040"],
|
||||
"--color-green": ["#00C73E", "#8774E1"],
|
||||
"--color-green-darker": ["#00a734", "#7b71c6"],
|
||||
"--color-active": ["#00C73E", "#8774E1"],
|
||||
"--color-active-darker": ["#00a734", "#7b71c6"],
|
||||
"--color-green": ["#00C73E", "#00C73E"],
|
||||
"--color-green-darker": ["#00A734", "#00A734"],
|
||||
"--color-success": ["#00C73E", "#00C73E"],
|
||||
"--color-text-meta-colored": ["#4DCD5E", "#8378DB"],
|
||||
"--color-reply-hover": ["#F4F4F4", "#272727"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user