Skip to content

Commit

Permalink
feat(ScreenSpinner): add subcomponents (#7250)
Browse files Browse the repository at this point in the history
добавилены подкомпоненты и context для передачи общего состояния
исправилена доступность state=cancelable (+ немного визуальное отображение - курсор теперь меняется только при наведении на сам спиннер с подложкой)
  • Loading branch information
BlackySoul authored Jul 30, 2024
1 parent 0c8bb0b commit 1dd7bbc
Show file tree
Hide file tree
Showing 9 changed files with 203 additions and 101 deletions.
29 changes: 16 additions & 13 deletions packages/vkui/src/components/Clickable/Clickable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {
useFocusVisibleClassName,
} from '../../hooks/useFocusVisibleClassName';
import { mergeCalls } from '../../lib/mergeCalls';
import { clickByKeyboardHandler } from '../../lib/utils';
import { RootComponent, RootComponentProps } from '../RootComponent/RootComponent';
import { useKeyboard } from './useKeyboard';
import {
ClickableLockStateContext,
DEFAULT_ACTIVE_EFFECT_DELAY,
Expand Down Expand Up @@ -74,18 +74,21 @@ const RealClickable = <T,>({
activated,
});

const keyboardHandlers = useKeyboard();

const handlers = mergeCalls(focusEvents, stateEvents, keyboardHandlers, {
onPointerEnter,
onPointerLeave,
onPointerDown,
onPointerCancel,
onPointerUp,
onBlur,
onFocus,
onKeyDown,
});
const handlers = mergeCalls(
focusEvents,
stateEvents,
{ onKeyDown: clickByKeyboardHandler },
{
onPointerEnter,
onPointerLeave,
onPointerDown,
onPointerCancel,
onPointerUp,
onBlur,
onFocus,
onKeyDown,
},
);

return (
<RootComponent
Expand Down
26 changes: 0 additions & 26 deletions packages/vkui/src/components/Clickable/useKeyboard.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as React from 'react';
import { classNames } from '@vkontakte/vkjs';
import { useKeyboard } from '../../../components/Clickable/useKeyboard';
import { useAdaptivityHasPointer } from '../../../hooks/useAdaptivityHasPointer';
import { useAppearance } from '../../../hooks/useAppearance';
import { useExternRef } from '../../../hooks/useExternRef';
import { useFocusVisible } from '../../../hooks/useFocusVisible';
import { useFocusVisibleClassName } from '../../../hooks/useFocusVisibleClassName';
import { clickByKeyboardHandler } from '../../../lib/utils';
import { ImageBaseContext } from '../context';
import { validateOverlayIcon } from '../validators';
import { useNonInteractiveOverlayProps } from './hooks';
Expand Down Expand Up @@ -41,7 +41,6 @@ const ImageBaseOverlayInteractive = ({
}: ImageBaseOverlayInteractiveProps & { overlayShown?: boolean }) => {
const { focusVisible, ...focusEvents } = useFocusVisible();
const focusVisibleClassNames = useFocusVisibleClassName({ focusVisible, mode: 'inside' });
const keyboardHandlers = useKeyboard();

return (
<>
Expand All @@ -56,8 +55,8 @@ const ImageBaseOverlayInteractive = ({
className,
)}
ref={getRootRef}
onKeyDown={clickByKeyboardHandler}
{...focusEvents}
{...keyboardHandlers}
>
{children}
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/vkui/src/components/Placeholder/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const onNavClick = (e) => {
</View>;
```

## Покомпоненты
## Подкомпоненты

```jsx { "props": { "layout": false, "iframe": false } }
<Placeholder.Container>
Expand Down
15 changes: 15 additions & 0 deletions packages/vkui/src/components/ScreenSpinner/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,18 @@ const setCancelableScreenSpinner = () => {
</SplitCol>
</SplitLayout>;
```

## Подкомпоненты

```jsx { "props": { "layout": false, "iframe": false } }
<Flex margin="auto" gap="m">
<ScreenSpinner.Container>
<ScreenSpinner.Loader />
<ScreenSpinner.SwapIcon />
</ScreenSpinner.Container>
<ScreenSpinner.Container state="cancelable">
<ScreenSpinner.Loader />
<ScreenSpinner.SwapIcon />
</ScreenSpinner.Container>
</Flex>
```
30 changes: 14 additions & 16 deletions packages/vkui/src/components/ScreenSpinner/ScreenSpinner.module.css
Original file line number Diff line number Diff line change
@@ -1,30 +1,24 @@
.ScreenSpinner {
position: relative;
inline-size: 88px;
block-size: 88px;
background: var(--vkui--color_background_contrast_themed);
box-shadow: var(--vkui--elevation4);
border-radius: var(--vkui--size_border_radius--regular);
color: var(--vkui--color_icon_medium);
animation: screen-spinner-intro 0.3s ease;
}

.ScreenSpinner--clickable {
cursor: pointer;
}

.ScreenSpinner__spinner {
opacity: 1;
transition: opacity 0.1s ease;
}

.ScreenSpinner__spinner--hidden {
.ScreenSpinner--state-done .ScreenSpinner__spinner,
.ScreenSpinner--state-error .ScreenSpinner__spinner {
opacity: 0;
}

.ScreenSpinner__container {
position: relative;
inline-size: 88px;
block-size: 88px;
background: var(--vkui--color_background_contrast_themed);
box-shadow: var(--vkui--elevation4);
border-radius: var(--vkui--size_border_radius--regular);
color: var(--vkui--color_icon_medium);
}

.ScreenSpinner__icon {
position: absolute;
inset-block-start: 0;
Expand All @@ -35,13 +29,17 @@
justify-content: center;
}

.ScreenSpinner--state-cancelable .ScreenSpinner__icon {
cursor: pointer;
}

/* stylelint-disable-next-line selector-pseudo-class-disallowed-list */
.ScreenSpinner__icon :global(.vkuiIcon) {
animation: screen-spinner-intro 0.2s ease;
}

/* stylelint-disable-next-line selector-max-type, selector-pseudo-class-disallowed-list */
.ScreenSpinner__icon--state-done :global(.vkuiIcon) path {
.ScreenSpinner--state-done :global(.vkuiIcon) path {
stroke-dasharray: 50;
stroke-dashoffset: 50;
animation: screen-spinner-icon-done 0.6s 0.3s var(--vkui--animation_easing_platform) forwards;
Expand Down
172 changes: 132 additions & 40 deletions packages/vkui/src/components/ScreenSpinner/ScreenSpinner.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,161 @@
import * as React from 'react';
import { Icon24Cancel } from '@vkontakte/icons';
import { classNames } from '@vkontakte/vkjs';
import { mergeCalls } from '../../lib/mergeCalls';
import { clickByKeyboardHandler } from '../../lib/utils';
import { HTMLAttributesWithRootRef } from '../../types';
import { useScrollLock } from '../AppRoot/ScrollContext';
import { PopoutWrapper } from '../PopoutWrapper/PopoutWrapper';
import { RootComponent } from '../RootComponent/RootComponent';
import { Spinner, SpinnerProps } from '../Spinner/Spinner';
import { Icon48CancelCircle } from './Icon48CancelCircle';
import { Icon48DoneOutline } from './Icon48DoneOutline';
import styles from './ScreenSpinner.module.css';

export interface ScreenSpinnerProps extends SpinnerProps {
state?: 'loading' | 'cancelable' | 'done' | 'error';
cancelLabel?: string;
}

/**
* @see https://vkcom.github.io/VKUI/#/ScreenSpinner
*/
export const ScreenSpinner = ({
style,
className,
state = 'loading',
export interface ScreenSpinnerContextProps {
state: NonNullable<ScreenSpinnerProps['state']>;
}

export const ScreenSpinnerContext: React.Context<ScreenSpinnerContextProps> =
React.createContext<ScreenSpinnerContextProps>({
state: 'loading',
});

const stateClassNames = {
cancelable: styles['ScreenSpinner--state-cancelable'],
done: styles['ScreenSpinner--state-done'],
error: styles['ScreenSpinner--state-error'],
};

const ScreenSpinnerLoader: React.FC<SpinnerProps> = ({
size = 'large',
onClick,
children = 'Пожалуйста, подождите...',
...restProps
}: ScreenSpinnerProps): React.ReactNode => {
const hideSpinner = state === 'done' || state === 'error';
}: SpinnerProps) => {
return (
<Spinner className={styles['ScreenSpinner__spinner']} size={size} {...restProps}>
{children}
</Spinner>
);
};

ScreenSpinnerLoader.displayName = 'ScreenSpinner.Loader';

type ScreenSpinnerSwapIconProps = HTMLAttributesWithRootRef<HTMLElement> & {
cancelLabel?: ScreenSpinnerProps['cancelLabel'];
};

const ScreenSpinnerCancelIcon: React.FC<ScreenSpinnerSwapIconProps> = ({
onKeyDown,
'aria-label': ariaLabel = 'Отменить',
...restProps
}: ScreenSpinnerSwapIconProps) => {
const handlers = mergeCalls(
{ onKeyDown: clickByKeyboardHandler },
{
onKeyDown,
},
);
let clickableProps: React.HTMLAttributes<HTMLSpanElement> = {
...handlers,
'tabIndex': 0,
'role': 'button',
'aria-label': ariaLabel,
};

return (
<RootComponent baseClassName={styles['ScreenSpinner__icon']} {...clickableProps} {...restProps}>
<Icon24Cancel />
</RootComponent>
);
};

const ScreenSpinnerSwapIcon: React.FC<ScreenSpinnerSwapIconProps> = ({
cancelLabel,
...restProps
}: ScreenSpinnerSwapIconProps) => {
const { state } = React.useContext(ScreenSpinnerContext);

if (state === 'cancelable') {
return <ScreenSpinnerCancelIcon aria-label={cancelLabel} {...restProps} />;
}

const Icon = {
loading: () => null,
cancelable: Icon24Cancel,
done: Icon48DoneOutline,
error: Icon48CancelCircle,
}[state];

return (
<RootComponent baseClassName={styles['ScreenSpinner__icon']} {...restProps}>
<Icon />
</RootComponent>
);
};

ScreenSpinnerSwapIcon.displayName = 'ScreenSpinner.SwapIcon';

type ScreenSpinnerContainerProps = HTMLAttributesWithRootRef<HTMLSpanElement> &
Pick<ScreenSpinnerProps, 'state'>;

const ScreenSpinnerContainer: React.FC<ScreenSpinnerContainerProps> = ({
state = 'loading',
...restProps
}: ScreenSpinnerContainerProps) => {
return (
<ScreenSpinnerContext.Provider value={{ state }}>
<RootComponent
baseClassName={classNames(
styles['ScreenSpinner'],
state !== 'loading' && stateClassNames[state],
)}
{...restProps}
/>
</ScreenSpinnerContext.Provider>
);
};

ScreenSpinnerContainer.displayName = 'ScreenSpinner.Container';

/**
* @see https://vkcom.github.io/VKUI/#/ScreenSpinner
*/
export const ScreenSpinner: React.FC<ScreenSpinnerProps> & {
Container: typeof ScreenSpinnerContainer;
Loader: typeof ScreenSpinnerLoader;
SwapIcon: typeof ScreenSpinnerSwapIcon;
} = ({
style,
className,
state = 'loading',
onClick,
cancelLabel,
...restProps
}: ScreenSpinnerProps): React.ReactNode => {
useScrollLock();

return (
<PopoutWrapper
noBackground
className={classNames(
styles['ScreenSpinner'],
state === 'cancelable' && styles['ScreenSpinner--clickable'],
className,
)}
style={style}
>
<div className={styles['ScreenSpinner__container']} onClick={onClick}>
<Spinner
className={classNames(
styles['ScreenSpinner__spinner'],
hideSpinner && styles['ScreenSpinner__spinner--hidden'],
)}
size={size}
{...restProps}
>
{children}
</Spinner>
<div
className={classNames(
styles['ScreenSpinner__icon'],
state === 'done' && styles['ScreenSpinner__icon--state-done'],
)}
>
<Icon />
</div>
</div>
<PopoutWrapper className={className} style={style} noBackground>
<ScreenSpinnerContainer state={state}>
<ScreenSpinnerLoader {...restProps} />
<ScreenSpinnerSwapIcon onClick={onClick} cancelLabel={cancelLabel} />
</ScreenSpinnerContainer>
</PopoutWrapper>
);
};

ScreenSpinner.displayName = 'ScreenSpinner';

ScreenSpinner.Container = ScreenSpinnerContainer;
ScreenSpinner.Container.displayName = 'ScreenSpinner.Container';

ScreenSpinner.Loader = ScreenSpinnerLoader;
ScreenSpinner.Loader.displayName = 'ScreenSpinner.Loader';

ScreenSpinner.SwapIcon = ScreenSpinnerSwapIcon;
ScreenSpinner.SwapIcon.displayName = 'ScreenSpinner.SwapIcon';
Loading

0 comments on commit 1dd7bbc

Please sign in to comment.