UI: Introduce new set of controls (#6825)

This commit is contained in:
zubiden 2026-04-14 14:36:18 +02:00 committed by Alexander Zinchuk
parent ac07a2242b
commit 97ba6b8031
31 changed files with 1558 additions and 60 deletions

View File

@ -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';

View File

@ -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 & {

View File

@ -560,7 +560,7 @@
height: 0.5rem;
border-radius: 50%;
background: var(--color-green-darker);
background: var(--color-active-darker);
}
}

View File

@ -29,7 +29,7 @@
}
.status-open {
color: var(--color-green);
color: var(--color-active);
}
.arrow {

View 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;
}
}

View 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,
};

View 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;
}
}

View 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);

View 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);
}
}
}

View 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);

View 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;
}
}
}

View 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);

View 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);
}
}
}

View 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);

View 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);

View 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);

View File

@ -54,7 +54,7 @@
.unread,
.unopened {
color: var(--color-white);
background: var(--color-green);
background: var(--color-active);
}
.unopened {

View File

@ -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>

View File

@ -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;

View File

@ -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 {

View File

@ -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;

View File

@ -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>

View 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;

View File

@ -13,6 +13,8 @@
}
.ripple-container {
pointer-events: none;
position: absolute;
top: 0;
right: 0;

View File

@ -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"

View File

@ -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
View 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,
};
}

View File

@ -54,7 +54,7 @@
<style>
@layer reset, variables, ui, components;
@layer ui {
@layer tablist, spinner, button;
@layer tablist, spinner, button, input;
}
</style>
</head>

View File

@ -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)};

View File

@ -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);

View File

@ -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"],