diff --git a/src/components/App.tsx b/src/components/App.tsx index dbbb1340a..43a14b5b8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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'; diff --git a/src/components/common/Audio.scss b/src/components/common/Audio.scss index 91ddd21ff..9abf8a24e 100644 --- a/src/components/common/Audio.scss +++ b/src/components/common/Audio.scss @@ -119,8 +119,8 @@ .icon-view-once, .media-loading { --color-primary: var(--color-text-green); - --color-primary-shade: var(--color-green); - --color-primary-shade-darker: var(--color-green-darker); + --color-primary-shade: var(--color-active); + --color-primary-shade-darker: var(--color-active-darker); --color-white: var(--color-background-own); .theme-dark & { diff --git a/src/components/common/Composer.scss b/src/components/common/Composer.scss index 61298bf70..be36743ff 100644 --- a/src/components/common/Composer.scss +++ b/src/components/common/Composer.scss @@ -560,7 +560,7 @@ height: 0.5rem; border-radius: 50%; - background: var(--color-green-darker); + background: var(--color-active-darker); } } diff --git a/src/components/common/profile/BusinessHours.module.scss b/src/components/common/profile/BusinessHours.module.scss index 2d7e96ba5..caa379988 100644 --- a/src/components/common/profile/BusinessHours.module.scss +++ b/src/components/common/profile/BusinessHours.module.scss @@ -29,7 +29,7 @@ } .status-open { - color: var(--color-green); + color: var(--color-active); } .arrow { diff --git a/src/components/gili/layout/Control.module.scss b/src/components/gili/layout/Control.module.scss new file mode 100644 index 000000000..61f006337 --- /dev/null +++ b/src/components/gili/layout/Control.module.scss @@ -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; + } +} diff --git a/src/components/gili/layout/Control.tsx b/src/components/gili/layout/Control.tsx new file mode 100644 index 000000000..21f7bc4d3 --- /dev/null +++ b/src/components/gili/layout/Control.tsx @@ -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(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 ( + +
+ {interactive?.isLoading && } + {children} +
+
+ ); +}; + +// #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 ( + + ); + } + + return ( + + {children} + + ); +} + +const ControlLabel = (props: ControlTextProps) => ( + +); + +const ControlDescription = (props: ControlTextProps) => ( + +); + +// #endregion + +// #region ControlBefore / ControlAfter + +type ControlSlotProps = { + className?: string; + children: TeactNode; +}; + +const ControlBefore = ({ className, children }: ControlSlotProps) => { + return ( +
+ {children} +
+ ); +}; + +const ControlAfter = ({ className, children }: ControlSlotProps) => { + return ( +
+ {children} +
+ ); +}; + +// #endregion + +export default memo(Control); +export { + ControlLabel, + ControlDescription, + ControlBefore, + ControlAfter, +}; diff --git a/src/components/gili/layout/Interactive.module.scss b/src/components/gili/layout/Interactive.module.scss new file mode 100644 index 000000000..e554808c2 --- /dev/null +++ b/src/components/gili/layout/Interactive.module.scss @@ -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; + } +} diff --git a/src/components/gili/layout/Interactive.tsx b/src/components/gili/layout/Interactive.tsx new file mode 100644 index 000000000..64bbdc44d --- /dev/null +++ b/src/components/gili/layout/Interactive.tsx @@ -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(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) => 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 && } + + ); + + if (asLabel) { + return ( + + + + ); + } + + return ( + +
+ {content} +
+
+ ); +}; + +export default memo(Interactive); diff --git a/src/components/gili/primitives/Checkbox.module.scss b/src/components/gili/primitives/Checkbox.module.scss new file mode 100644 index 000000000..7c25ddaa5 --- /dev/null +++ b/src/components/gili/primitives/Checkbox.module.scss @@ -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); + } + } +} diff --git a/src/components/gili/primitives/Checkbox.tsx b/src/components/gili/primitives/Checkbox.tsx new file mode 100644 index 000000000..dde104d5f --- /dev/null +++ b/src/components/gili/primitives/Checkbox.tsx @@ -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 +>; + +type OwnProps = { + checked: boolean; + disabled?: boolean; + isRound?: boolean; + indeterminate?: boolean; + isInvalid?: boolean; + className?: string; + onChange: (checked: boolean) => void; +}; + +type Props = OwnProps & Omit; + +const Checkbox = ({ + checked, + disabled, + isRound, + indeterminate, + isInvalid, + className, + onChange, + id, + ...restProps +}: Props) => { + const control = useControlContext(); + const interactive = useInteractiveContext(); + const ref = useRef(); + + 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) => { + onChange(e.currentTarget.checked); + }); + + if (interactive?.isLoading) return undefined; + + return ( + + ); +}; + +export default memo(Checkbox); diff --git a/src/components/gili/primitives/Radio.module.scss b/src/components/gili/primitives/Radio.module.scss new file mode 100644 index 000000000..2b44480b3 --- /dev/null +++ b/src/components/gili/primitives/Radio.module.scss @@ -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; + } + } +} diff --git a/src/components/gili/primitives/Radio.tsx b/src/components/gili/primitives/Radio.tsx new file mode 100644 index 000000000..0f23de9a8 --- /dev/null +++ b/src/components/gili/primitives/Radio.tsx @@ -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 +>; + +type OwnProps = { + value: string; + checked: boolean; + disabled?: boolean; + className?: string; + onChange: (value: string) => void; +}; + +type Props = OwnProps & Omit; + +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 ( + + ); +}; + +export default memo(Radio); diff --git a/src/components/gili/primitives/Switch.module.scss b/src/components/gili/primitives/Switch.module.scss new file mode 100644 index 000000000..0d94b7fc4 --- /dev/null +++ b/src/components/gili/primitives/Switch.module.scss @@ -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); + } + } +} diff --git a/src/components/gili/primitives/Switch.tsx b/src/components/gili/primitives/Switch.tsx new file mode 100644 index 000000000..3026eb9b6 --- /dev/null +++ b/src/components/gili/primitives/Switch.tsx @@ -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 +>; + +type OwnProps = { + checked: boolean; + disabled?: boolean; + withPermissionColors?: boolean; + className?: string; + onChange: (checked: boolean) => void; +}; + +type Props = OwnProps & Omit; + +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) => { + onChange(e.currentTarget.checked); + }); + + if (interactive?.isLoading) return undefined; + + return ( + + ); +}; + +export default memo(Switch); diff --git a/src/components/gili/templates/CheckboxField.tsx b/src/components/gili/templates/CheckboxField.tsx new file mode 100644 index 000000000..8698a09f0 --- /dev/null +++ b/src/components/gili/templates/CheckboxField.tsx @@ -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, '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 ( + + + + {label} + {description !== undefined ? ( + + {description} + + ) : undefined} + + + ); +}; + +export default memo(CheckboxField); diff --git a/src/components/gili/templates/SwitchField.tsx b/src/components/gili/templates/SwitchField.tsx new file mode 100644 index 000000000..f5f149231 --- /dev/null +++ b/src/components/gili/templates/SwitchField.tsx @@ -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, '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 ( + + + + {label} + {description !== undefined ? ( + + {description} + + ) : undefined} + + + ); +}; + +export default memo(SwitchField); diff --git a/src/components/left/main/ChatBadge.module.scss b/src/components/left/main/ChatBadge.module.scss index c6ff888c1..240e4d809 100644 --- a/src/components/left/main/ChatBadge.module.scss +++ b/src/components/left/main/ChatBadge.module.scss @@ -54,7 +54,7 @@ .unread, .unopened { color: var(--color-white); - background: var(--color-green); + background: var(--color-active); } .unopened { diff --git a/src/components/main/premium/GiveawayModal.tsx b/src/components/main/premium/GiveawayModal.tsx index c23f96501..d92b25e8e 100644 --- a/src/components/main/premium/GiveawayModal.tsx +++ b/src/components/main/premium/GiveawayModal.tsx @@ -423,14 +423,6 @@ const GiveawayModal: FC = ({ setSelectedChannelIds(newSelectedIds); }); - const handleShouldShowWinnersChange = useLastCallback((e: ChangeEvent) => { - setShouldShowWinners(e.target.checked); - }); - - const handleShouldShowPrizesChange = useLastCallback((e: ChangeEvent) => { - setShouldShowPrizes(e.target.checked); - }); - const onClickActionHandler = useLastCallback(() => { openCountryPickerModal(); }); @@ -600,7 +592,7 @@ const GiveawayModal: FC = ({ @@ -650,7 +642,7 @@ const GiveawayModal: FC = ({ diff --git a/src/components/middle/ScrollDownButton.module.scss b/src/components/middle/ScrollDownButton.module.scss index d32583963..79880690a 100644 --- a/src/components/middle/ScrollDownButton.module.scss +++ b/src/components/middle/ScrollDownButton.module.scss @@ -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; diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index 4e77d4ac6..2f3f93825 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -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 { diff --git a/src/components/middle/panes/AudioPlayer.scss b/src/components/middle/panes/AudioPlayer.scss index 70149aa9d..2776a5cc6 100644 --- a/src/components/middle/panes/AudioPlayer.scss +++ b/src/components/middle/panes/AudioPlayer.scss @@ -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; diff --git a/src/components/modals/gift/GiftComposer.tsx b/src/components/modals/gift/GiftComposer.tsx index 7be021781..835c6ef1e 100644 --- a/src/components/modals/gift/GiftComposer.tsx +++ b/src/components/modals/gift/GiftComposer.tsx @@ -279,7 +279,7 @@ function GiftComposer({ @@ -319,7 +319,7 @@ function GiftComposer({ @@ -358,7 +358,7 @@ function GiftComposer({ {lang('GiftHideMyName')} diff --git a/src/components/test/FieldTest.tsx b/src/components/test/FieldTest.tsx new file mode 100644 index 000000000..5b34cedc5 --- /dev/null +++ b/src/components/test/FieldTest.tsx @@ -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 ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} + +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 ( +
+
+

Control Component Test

+ + {/* Bare primitives */} +
+
+ + + + + + + + + + + + + + +
+
+ +
+ + + + +
+ + {/* Interactive + Control with ControlLabel (auto-linked via context) */} +
+ + + + Accept terms and conditions + + + + + + Remember me on this device + + +
+ + {/* Radio in Control */} +
+ + + + Click description to toggle + Clicking anywhere toggles the checkbox + + + + + + Option A + Click anywhere on this field + + + + + + Option B + Including the description text + + +
+ + {/* Control + Label + Description */} +
+ + + + Keep signed in + Your session will persist across browser restarts and device reboots + + + + + + Enable two-factor authentication + Adds an extra layer of security + + +
+ + {/* Round checkboxes */} +
+ + + + Round checkbox + + + + + + Round pre-checked + This one started checked + + +
+ + {/* Indeterminate */} +
+ + + { + setItemA(v); + setItemB(v); + setItemC(v); + }} + /> + Select all + + {allChecked ? 'All selected' : noneChecked ? 'None selected' : 'Some selected'} + + + + + + + Item A + + + + + + Item B + + + + + + Item C + + +
+ + {/* Radio group */} +
+ + + + Default spacing + Standard spacing for most use cases + + + + + + Comfortable + More space between elements + + + + + + Compact + Minimal spacing for dense layouts + + +
+ + {/* Switch */} +
+ + + + Only Accept TON + + + + + + Enable notifications + Receive push notifications for new messages + + + + + + Auto-download media + + + + + + + This is a long label that wraps to multiple lines to verify vertical centering with the switch + + And a description underneath for good measure + + + + + + Disabled switch (on) + + + + + + Loading switch + Spinner replaces the switch + + +
+ + {/* Disabled */} +
+ + + + Disabled unchecked + + + + + + Disabled checked + This option is currently unavailable + + + + + + Disabled radio + + +
+ + {/* inputEnd */} +
+ + + + Enable notifications + Receive alerts for new messages + + + + + + Auto-download media + + + + + + Disabled at end + Cannot toggle this + + +
+ + {/* ControlAfter */} +
+ + + + Notifications + Get notified about updates + + + 3 + + + + + + + + inputEnd + after + + + + + +
+ + {/* ControlBefore */} +
+ + + + +
+ AB +
+
+ Alice Brown + Online +
+
+ + + + +
+ CD +
+
+ Charlie Davis + Last seen recently +
+
+
+ + {/* ControlBefore + ControlAfter */} +
+ + + + +
+ JD +
+
+ John Doe + Admin + + Owner + +
+
+
+ + {/* Long content */} +
+ + + + + This is a very long label text that should wrap to multiple lines to verify the checkbox stays vertically centered + + + 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 + + + + + + + + Another long label to test inputEnd centering with wrapping text content + + + Description wrapping for the inputEnd variant showing the checkbox on the right side + + + +
+ + {/* Loading */} +
+ + + + Loading (label only) + + + + + + Loading with description + Spinner replaces the checkbox + + + + + + Loading at end + inputEnd + loading + + + + + + Loading radio + Spinner replaces the radio button + + +
+ + {/* Control without Interactive */} +
+
+ + + Bare field, custom container + + Control only handles grid layout +
+ Description is clickable too +
+
+
+
+
+
+ ); +}; + +export default FieldTest; diff --git a/src/components/ui/RippleEffect.scss b/src/components/ui/RippleEffect.scss index e3420b8f1..b89441ae8 100644 --- a/src/components/ui/RippleEffect.scss +++ b/src/components/ui/RippleEffect.scss @@ -13,6 +13,8 @@ } .ripple-container { + pointer-events: none; + position: absolute; top: 0; right: 0; diff --git a/src/components/ui/RippleEffect.tsx b/src/components/ui/RippleEffect.tsx index 1fa2a81c8..cd4e960bf 100644 --- a/src/components/ui/RippleEffect.tsx +++ b/src/components/ui/RippleEffect.tsx @@ -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([]); + const containerRef = useRef(); const cleanUpDebounced = useMemo(() => { return debounce(() => { @@ -23,17 +24,17 @@ const RippleEffect = () => { }, ANIMATION_DURATION_MS, false); }, []); - const handleMouseDown = useLastCallback((e: React.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 ( -
+
{ripples.map(({ x, y, size }) => (
) => void; onCheck?: (isChecked: boolean) => void; }; -const Switcher: FC = ({ +const Switcher = ({ id, name, value, @@ -28,18 +27,11 @@ const Switcher: FC = ({ disabled, inactive, noAnimation, - onChange, onCheck, -}) => { - const handleChange = useCallback((e: ChangeEvent) => { - if (onChange) { - onChange(e); - } - - if (onCheck) { - onCheck(e.currentTarget.checked); - } - }, [onChange, onCheck]); +}: OwnProps) => { + const handleChange = useLastCallback((e: React.ChangeEvent) => { + onCheck?.(e.currentTarget.checked); + }); const className = buildClassName( 'Switcher', diff --git a/src/hooks/useClickable.ts b/src/hooks/useClickable.ts new file mode 100644 index 000000000..853c767b7 --- /dev/null +++ b/src/hooks/useClickable.ts @@ -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 = Partial, + 'onClick' | 'onKeyDown' | 'onKeyUp' | 'role' | 'tabIndex' | 'aria-disabled' +>>; + +// WAI-ARIA keyboard activation patterns per role +const ROLES_WITH_ENTER_ACTIVATION = new Set([ + 'button', 'link', 'menuitem', 'switch', 'tab', 'treeitem', +]); + +const ROLES_WITH_SPACE_ACTIVATION = new Set([ + 'button', 'switch', 'checkbox', 'radio', 'option', 'tab', 'treeitem', +]); + +export default function useClickable( + onPress?: (e: React.MouseEvent) => void, + { + disabled, + role = 'button', + withA11y = true, + tabIndex = 0, + }: UseClickableOptions = {}, +): ClickableProps { + const shouldHandleSyntheticClickRef = useRef(false); + const withListeners = Boolean(onPress) && !disabled; + + const handlePress = useLastCallback((e: React.MouseEvent) => { + 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) => { + 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) => { + 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, + }; +} diff --git a/src/index.html b/src/index.html index 3c7fc2c9e..b634be321 100644 --- a/src/index.html +++ b/src/index.html @@ -54,7 +54,7 @@ diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index d0d944017..298fa65fd 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -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)}; diff --git a/src/styles/index.scss b/src/styles/index.scss index 9ba215a88..5fcfab0ec 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -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); diff --git a/src/styles/themes.json b/src/styles/themes.json index 48fed7078..cd802fd46 100644 --- a/src/styles/themes.json +++ b/src/styles/themes.json @@ -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"],