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 AppInactive from './main/AppInactive';
|
||||||
import LockScreen from './main/LockScreen.async';
|
import LockScreen from './main/LockScreen.async';
|
||||||
import Main from './main/Main.async';
|
import Main from './main/Main.async';
|
||||||
// import Test from './test/TestDateFormat';
|
// import Test from './test/FieldTest.tsx';
|
||||||
import Transition from './ui/Transition';
|
import Transition from './ui/Transition';
|
||||||
|
|
||||||
import styles from './App.module.scss';
|
import styles from './App.module.scss';
|
||||||
|
|||||||
@ -119,8 +119,8 @@
|
|||||||
.icon-view-once,
|
.icon-view-once,
|
||||||
.media-loading {
|
.media-loading {
|
||||||
--color-primary: var(--color-text-green);
|
--color-primary: var(--color-text-green);
|
||||||
--color-primary-shade: var(--color-green);
|
--color-primary-shade: var(--color-active);
|
||||||
--color-primary-shade-darker: var(--color-green-darker);
|
--color-primary-shade-darker: var(--color-active-darker);
|
||||||
--color-white: var(--color-background-own);
|
--color-white: var(--color-background-own);
|
||||||
|
|
||||||
.theme-dark & {
|
.theme-dark & {
|
||||||
|
|||||||
@ -560,7 +560,7 @@
|
|||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
||||||
background: var(--color-green-darker);
|
background: var(--color-active-darker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-open {
|
.status-open {
|
||||||
color: var(--color-green);
|
color: var(--color-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow {
|
.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,
|
.unread,
|
||||||
.unopened {
|
.unopened {
|
||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
background: var(--color-green);
|
background: var(--color-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.unopened {
|
.unopened {
|
||||||
|
|||||||
@ -423,14 +423,6 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
|
|||||||
setSelectedChannelIds(newSelectedIds);
|
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(() => {
|
const onClickActionHandler = useLastCallback(() => {
|
||||||
openCountryPickerModal();
|
openCountryPickerModal();
|
||||||
});
|
});
|
||||||
@ -600,7 +592,7 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
|
|||||||
<Switcher
|
<Switcher
|
||||||
label={lang('BoostingGiveawayAdditionalPrizes')}
|
label={lang('BoostingGiveawayAdditionalPrizes')}
|
||||||
checked={shouldShowPrizes}
|
checked={shouldShowPrizes}
|
||||||
onChange={handleShouldShowPrizesChange}
|
onCheck={setShouldShowPrizes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -650,7 +642,7 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
|
|||||||
<Switcher
|
<Switcher
|
||||||
label={lang('BoostingGiveawayAdditionalPrizes')}
|
label={lang('BoostingGiveawayAdditionalPrizes')}
|
||||||
checked={shouldShowWinners}
|
checked={shouldShowWinners}
|
||||||
onChange={handleShouldShowWinnersChange}
|
onCheck={setShouldShowWinners}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -56,7 +56,7 @@
|
|||||||
color: white;
|
color: white;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
background-color: var(--color-green);
|
background-color: var(--color-active);
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
top: -0.6875rem;
|
top: -0.6875rem;
|
||||||
|
|||||||
@ -197,7 +197,7 @@
|
|||||||
--active-color: var(--color-reply-own-active);
|
--active-color: var(--color-reply-own-active);
|
||||||
--max-width: 30rem;
|
--max-width: 30rem;
|
||||||
--accent-color: var(--color-accent-own);
|
--accent-color: var(--color-accent-own);
|
||||||
--accent-shade-color: var(--color-green);
|
--accent-shade-color: var(--color-active);
|
||||||
--secondary-color: var(--color-accent-own);
|
--secondary-color: var(--color-accent-own);
|
||||||
--color-code: var(--color-code-own);
|
--color-code: var(--color-code-own);
|
||||||
--color-code-bg: var(--color-code-own-bg);
|
--color-code-bg: var(--color-code-own-bg);
|
||||||
@ -411,13 +411,13 @@
|
|||||||
|
|
||||||
&.is-selected {
|
&.is-selected {
|
||||||
.message-select-control {
|
.message-select-control {
|
||||||
background: var(--color-green);
|
background: var(--color-active);
|
||||||
|
|
||||||
&.group-select {
|
&.group-select {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
&.is-selected {
|
&.is-selected {
|
||||||
background: var(--color-green);
|
background: var(--color-active);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -425,7 +425,7 @@
|
|||||||
|
|
||||||
.File.file-is-selected {
|
.File.file-is-selected {
|
||||||
.message-select-control {
|
.message-select-control {
|
||||||
background: var(--color-green);
|
background: var(--color-active);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -488,7 +488,7 @@
|
|||||||
|
|
||||||
&.is-selected {
|
&.is-selected {
|
||||||
.message-select-control {
|
.message-select-control {
|
||||||
background: var(--color-green);
|
background: var(--color-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
img,
|
img,
|
||||||
@ -501,7 +501,7 @@
|
|||||||
&.is-selected,
|
&.is-selected,
|
||||||
&.is-forwarding {
|
&.is-forwarding {
|
||||||
.message-select-control {
|
.message-select-control {
|
||||||
background: var(--color-green);
|
background: var(--color-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.Menu .bubble {
|
.Menu .bubble {
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
.player-button {
|
.player-button {
|
||||||
--color-text-secondary: var(--color-primary);
|
--color-text-secondary: var(--color-primary);
|
||||||
--color-text-secondary-rgb: var(--color-primary-shade-rgb);
|
--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);
|
--color-white: var(--color-background-own);
|
||||||
|
|
||||||
margin: 0.125rem;
|
margin: 0.125rem;
|
||||||
|
|||||||
@ -279,7 +279,7 @@ function GiftComposer({
|
|||||||
</span>
|
</span>
|
||||||
<Switcher
|
<Switcher
|
||||||
checked={shouldPayByStars}
|
checked={shouldPayByStars}
|
||||||
onChange={toggleShouldPayByStars}
|
inactive
|
||||||
label={lang('GiftPremiumPayWithStarsAcc')}
|
label={lang('GiftPremiumPayWithStarsAcc')}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@ -319,7 +319,7 @@ function GiftComposer({
|
|||||||
</span>
|
</span>
|
||||||
<Switcher
|
<Switcher
|
||||||
checked={shouldPayForUpgrade}
|
checked={shouldPayForUpgrade}
|
||||||
onChange={handleShouldPayForUpgradeChange}
|
inactive
|
||||||
label={lang('GiftMakeUniqueAcc')}
|
label={lang('GiftMakeUniqueAcc')}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@ -358,7 +358,7 @@ function GiftComposer({
|
|||||||
<span>{lang('GiftHideMyName')}</span>
|
<span>{lang('GiftHideMyName')}</span>
|
||||||
<Switcher
|
<Switcher
|
||||||
checked={shouldHideName}
|
checked={shouldHideName}
|
||||||
onChange={handleShouldHideNameChange}
|
inactive
|
||||||
label={lang('GiftHideMyName')}
|
label={lang('GiftHideMyName')}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</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 {
|
.ripple-container {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 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';
|
import { debounce } from '../../util/schedulers';
|
||||||
|
|
||||||
@ -16,6 +16,7 @@ const ANIMATION_DURATION_MS = 700;
|
|||||||
|
|
||||||
const RippleEffect = () => {
|
const RippleEffect = () => {
|
||||||
const [ripples, setRipples] = useState<Ripple[]>([]);
|
const [ripples, setRipples] = useState<Ripple[]>([]);
|
||||||
|
const containerRef = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
const cleanUpDebounced = useMemo(() => {
|
const cleanUpDebounced = useMemo(() => {
|
||||||
return debounce(() => {
|
return debounce(() => {
|
||||||
@ -23,17 +24,17 @@ const RippleEffect = () => {
|
|||||||
}, ANIMATION_DURATION_MS, false);
|
}, ANIMATION_DURATION_MS, false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMouseDown = useLastCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
const handleMouseDown = useLastCallback((e: MouseEvent) => {
|
||||||
if (e.button !== 0) {
|
if (e.button !== 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = e.currentTarget;
|
const parent = containerRef.current?.parentElement;
|
||||||
const position = container.getBoundingClientRect();
|
if (!parent) return;
|
||||||
const rippleSize = container.offsetWidth / 2;
|
|
||||||
|
|
||||||
setRipples([
|
const position = parent.getBoundingClientRect();
|
||||||
...ripples,
|
const rippleSize = parent.offsetWidth / 2;
|
||||||
|
|
||||||
|
setRipples((prev) => [
|
||||||
|
...prev,
|
||||||
{
|
{
|
||||||
x: e.clientX - position.x - (rippleSize / 2),
|
x: e.clientX - position.x - (rippleSize / 2),
|
||||||
y: e.clientY - position.y - (rippleSize / 2),
|
y: e.clientY - position.y - (rippleSize / 2),
|
||||||
@ -44,8 +45,16 @@ const RippleEffect = () => {
|
|||||||
cleanUpDebounced();
|
cleanUpDebounced();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parent = containerRef.current?.parentElement;
|
||||||
|
if (!parent) return undefined;
|
||||||
|
|
||||||
|
parent.addEventListener('mousedown', handleMouseDown);
|
||||||
|
return () => parent.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
}, [handleMouseDown]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ripple-container" onMouseDown={handleMouseDown}>
|
<div ref={containerRef} className="ripple-container">
|
||||||
{ripples.map(({ x, y, size }) => (
|
{ripples.map(({ x, y, size }) => (
|
||||||
<div
|
<div
|
||||||
className="ripple-wave"
|
className="ripple-wave"
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import type { ChangeEvent } from 'react';
|
import { memo } from '../../lib/teact/teact';
|
||||||
import type { FC } from '../../lib/teact/teact';
|
|
||||||
import { memo, useCallback } from '../../lib/teact/teact';
|
|
||||||
|
|
||||||
import buildClassName from '../../util/buildClassName';
|
import buildClassName from '../../util/buildClassName';
|
||||||
|
|
||||||
|
import useLastCallback from '../../hooks/useLastCallback';
|
||||||
|
|
||||||
import './Switcher.scss';
|
import './Switcher.scss';
|
||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
@ -15,11 +15,10 @@ type OwnProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
inactive?: boolean;
|
inactive?: boolean;
|
||||||
noAnimation?: boolean;
|
noAnimation?: boolean;
|
||||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
onCheck?: (isChecked: boolean) => void;
|
onCheck?: (isChecked: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Switcher: FC<OwnProps> = ({
|
const Switcher = ({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
@ -28,18 +27,11 @@ const Switcher: FC<OwnProps> = ({
|
|||||||
disabled,
|
disabled,
|
||||||
inactive,
|
inactive,
|
||||||
noAnimation,
|
noAnimation,
|
||||||
onChange,
|
|
||||||
onCheck,
|
onCheck,
|
||||||
}) => {
|
}: OwnProps) => {
|
||||||
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (onChange) {
|
onCheck?.(e.currentTarget.checked);
|
||||||
onChange(e);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (onCheck) {
|
|
||||||
onCheck(e.currentTarget.checked);
|
|
||||||
}
|
|
||||||
}, [onChange, onCheck]);
|
|
||||||
|
|
||||||
const className = buildClassName(
|
const className = buildClassName(
|
||||||
'Switcher',
|
'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>
|
<style>
|
||||||
@layer reset, variables, ui, components;
|
@layer reset, variables, ui, components;
|
||||||
@layer ui {
|
@layer ui {
|
||||||
@layer tablist, spinner, button;
|
@layer tablist, spinner, button, input;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -115,14 +115,18 @@
|
|||||||
--color-primary-opacity: rgba(var(--color-primary), 0.2);
|
--color-primary-opacity: rgba(var(--color-primary), 0.2);
|
||||||
--color-primary-opacity-hover: rgba(var(--color-primary), 0.25);
|
--color-primary-opacity-hover: rgba(var(--color-primary), 0.25);
|
||||||
--color-primary-tint: rgba(var(--color-primary), 0.1);
|
--color-primary-tint: rgba(var(--color-primary), 0.1);
|
||||||
--color-green: #{$color-green};
|
--color-active: #{$color-green};
|
||||||
--color-green-darker: #{color.mix($color-green, $color-black, 84%)};
|
--color-active-darker: #{color.mix($color-green, $color-black, 84%)};
|
||||||
--color-success: #{$color-green};
|
--color-success: #{$color-green};
|
||||||
|
|
||||||
--accent-color: var(--color-primary);
|
--accent-color: var(--color-primary);
|
||||||
--accent-background-color: var(--color-primary-tint);
|
--accent-background-color: var(--color-primary-tint);
|
||||||
--accent-background-active-color: var(--color-primary-opacity-hover);
|
--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: #{$color-error};
|
||||||
--color-error-shade: #{color.mix($color-error, $color-black, 92%)};
|
--color-error-shade: #{color.mix($color-error, $color-black, 92%)};
|
||||||
--color-error-rgb: #{toRGB($color-error)};
|
--color-error-rgb: #{toRGB($color-error)};
|
||||||
|
|||||||
@ -482,7 +482,7 @@ body:not(.is-ios) {
|
|||||||
--color-list-icon: rgb(112, 117, 121);
|
--color-list-icon: rgb(112, 117, 121);
|
||||||
--color-default-shadow: rgb(16, 16, 16, 0.612);
|
--color-default-shadow: rgb(16, 16, 16, 0.612);
|
||||||
--color-light-shadow: rgb(0, 0, 0, 0.251);
|
--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-success: rgb(0, 199, 62);
|
||||||
--color-text-meta-colored: rgb(131, 120, 219);
|
--color-text-meta-colored: rgb(131, 120, 219);
|
||||||
--color-reply-hover: rgb(39, 39, 39);
|
--color-reply-hover: rgb(39, 39, 39);
|
||||||
|
|||||||
@ -34,8 +34,10 @@
|
|||||||
"--color-list-icon": ["#ABAFB1", "#A2A2A2"],
|
"--color-list-icon": ["#ABAFB1", "#A2A2A2"],
|
||||||
"--color-default-shadow": ["#72727240", "#1010109c"],
|
"--color-default-shadow": ["#72727240", "#1010109c"],
|
||||||
"--color-light-shadow": ["#7272722B", "#00000040"],
|
"--color-light-shadow": ["#7272722B", "#00000040"],
|
||||||
"--color-green": ["#00C73E", "#8774E1"],
|
"--color-active": ["#00C73E", "#8774E1"],
|
||||||
"--color-green-darker": ["#00a734", "#7b71c6"],
|
"--color-active-darker": ["#00a734", "#7b71c6"],
|
||||||
|
"--color-green": ["#00C73E", "#00C73E"],
|
||||||
|
"--color-green-darker": ["#00A734", "#00A734"],
|
||||||
"--color-success": ["#00C73E", "#00C73E"],
|
"--color-success": ["#00C73E", "#00C73E"],
|
||||||
"--color-text-meta-colored": ["#4DCD5E", "#8378DB"],
|
"--color-text-meta-colored": ["#4DCD5E", "#8378DB"],
|
||||||
"--color-reply-hover": ["#F4F4F4", "#272727"],
|
"--color-reply-hover": ["#F4F4F4", "#272727"],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user