Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get
/static/app/views/performance/ @getsentry/data-browsing
/static/app/components/performance/ @getsentry/data-browsing
/static/app/utils/performance/ @getsentry/data-browsing
/static/app/components/events/groupingInfo @getsentry/data-browsing
/static/app/components/events/interfaces/spans/ @getsentry/data-browsing
/static/app/components/events/viewHierarchy/* @getsentry/data-browsing
/static/app/components/searchQueryBuilder/ @getsentry/data-browsing
Expand Down
70 changes: 56 additions & 14 deletions static/app/components/core/button/button.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ resources:
WAI-ARIA Button Practices: https://www.w3.org/WAI/ARIA/apg/patterns/button/
---

import {useState} from 'react';

import {Flex} from '@sentry/scraps/layout';
import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';

Expand Down Expand Up @@ -158,29 +161,68 @@ The following table defines standardized call to action copy and icon pairings f
| <IconZoom /> | `zoom` | Zoom Out | Applies to charts and zooms out (i.e. 200%) |
| <IconStar /> | `star` | Favorite | Hoisting item to primary view |

## Disabled and Busy Buttons
## Disabled Buttons

Disabled and busy buttons are used to indicate the status of an item. Busy buttons should be used to indicate that an action is in progress. Disabled buttons should be used to indicate that an action is not available.
Disabled buttons should be used to indicate that an action is not available.

<Storybook.Demo>
<Button disabled>Cancel</Button>
<Button disabled priority="primary">
Disabled
</Button>
<Button disabled>Disabled</Button>
<Button busy priority="primary">
Busy
Submit
</Button>
<Button busy>Busy</Button>
</Storybook.Demo>
```jsx
<Button disabled>Cancel</Button>
<Button disabled priority="primary">
Disabled
</Button>
<Button disabled>Disabled</Button>
<Button busy priority="primary">
Busy
Submit
</Button>
<Button busy>Busy</Button>
```

### Busy Buttons

Busy buttons should be used to indicate that an async action is in progress, usually connected to the `isPending` state of a query mutation.

export function BusyDemo() {
const [busy, setBusy] = useState({cancel: false, submit: false});
/** @param key {'cancel' | 'submit'} */
const handleClick = key => {
setBusy(v => ({...v, [key]: true}));
setTimeout(() => {
setBusy(v => ({...v, [key]: false}));
}, 2500);
};
return (
<Flex gap="md">
<Button busy={busy.cancel} priority="default" onClick={() => handleClick('cancel')}>
Cancel
</Button>
<Button busy={busy.submit} priority="primary" onClick={() => handleClick('submit')}>
Submit
</Button>
</Flex>
);
}

<Storybook.Demo>
<BusyDemo />
</Storybook.Demo>
```jsx
<Flex gap="md">
<Button
priority="secondary"
busy={cancelMutation.isPending}
onClick={cancelMutation.mutateAsync}
>
Cancel
</Button>
<Button
priority="primary"
busy={submitMutation.isPending}
onClick={submitMutation.mutateAsync}
>
Submit
</Button>
</Flex>
```

## Icon-only Buttons
Expand Down
23 changes: 2 additions & 21 deletions static/app/components/core/button/button.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {keyframes} from '@emotion/react';
import styled from '@emotion/styled';

import {Flex} from '@sentry/scraps/layout';
import {IndeterminateLoader} from '@sentry/scraps/loader';
import {useSizeContext} from '@sentry/scraps/sizeContext';
import {Tooltip} from '@sentry/scraps/tooltip';

Expand Down Expand Up @@ -86,7 +86,7 @@ export function Button({
visibility="visible"
inset={0}
>
{({className}) => <BusySpinner className={className} aria-hidden />}
<IndeterminateLoader variant="monochrome" aria-hidden />
</Flex>
)}
</Flex>
Expand All @@ -103,22 +103,3 @@ const StyledButton = styled('button')<
>`
${p => getButtonStyles(p)}
`;

const spin = keyframes`
to {
transform: rotate(360deg);
}
`;

const BusySpinner = styled('span')`
&::after {
content: '';
display: block;
width: 1em;
height: 1em;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
animation: ${spin} 0.6s linear infinite;
}
`;
12 changes: 10 additions & 2 deletions static/app/components/core/button/styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function DO_NOT_USE_getButtonStyles(

fontWeight: p.theme.font.weight.sans.medium,

opacity: p.busy || p.disabled ? 0.6 : undefined,
opacity: p.disabled ? 0.6 : undefined,

cursor: 'pointer',
'&[disabled]': {
Expand Down Expand Up @@ -135,6 +135,10 @@ export function DO_NOT_USE_getButtonStyles(
},
},

'&[aria-busy="true"] > span:last-child': {
overflow: 'visible',
},

'> span:last-child': {
zIndex: 1,
position: 'relative',
Expand Down Expand Up @@ -180,7 +184,7 @@ export function DO_NOT_USE_getButtonStyles(
},
},

'&:disabled, &[aria-disabled="true"]': {
'&:disabled, &[aria-disabled="true"], &[aria-busy="true"]': {
'&::after': {
transform: 'translateY(0px)',
},
Expand All @@ -189,6 +193,10 @@ export function DO_NOT_USE_getButtonStyles(
},
},

'&[aria-busy="true"]': {
cursor: 'progress',
},

...(p.priority === 'link' && {
transform: 'translateY(0px)',

Expand Down
215 changes: 215 additions & 0 deletions static/app/components/core/loader/indeterminateLoader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import {useEffect, useRef, useState} from 'react';
import {keyframes} from '@emotion/react';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';
import {useResizeObserver} from '@react-aria/utils';
import {AnimatePresence, motion} from 'framer-motion';

import {Stack} from '@sentry/scraps/layout';

import {testableTransition} from 'sentry/utils/testableTransition';

// required to break import cycle
// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths
import {Text} from '../text/text';

interface IndeterminateLoaderProps extends React.HTMLAttributes<HTMLDivElement> {
messages?: React.ReactNode[];
variant?: 'vibrant' | 'monochrome';
}

const SQUIGGLE_TILE = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='1 0 16 8'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M17 6c-4 0-4-4-8-4S5 6 1 6'/%3E%3C/svg%3E")`;

const indeterminateSlow = keyframes`
0% { left: -35%; right: 100%; }
60% { left: 100%; right: -90%; }
100% { left: 100%; right: -90%; }
`;

const indeterminateFast = keyframes`
0% { left: -200%; right: 100%; }
60% { left: 107%; right: -8%; }
100% { left: 107%; right: -8%; }
`;

// Lerp animation timing based on track width.
// Small (~128px): 2.0s duration, 1.0s delay
// Large (~400px+): 3.2s duration, 1.6s delay
const WIDTH = {MIN: 128, MAX: 400};
const DURATION = {MIN: 2.0, MAX: 2.8};
const DELAY = {MIN: 0.8, MAX: 1.2};

function lerp(min: number, max: number, t: number): number {
return min + (max - min) * Math.min(1, Math.max(0, t));
}

function useAnimationTiming() {
const ref = useRef<HTMLDivElement>(null);
const [duration, setDuration] = useState(DURATION.MAX);
const [delay, setDelay] = useState(DELAY.MAX);

useResizeObserver({
ref,
onResize() {
const w = ref.current?.offsetWidth ?? WIDTH.MAX;
const t = (w - WIDTH.MIN) / (WIDTH.MAX - WIDTH.MIN);
setDuration(lerp(DURATION.MIN, DURATION.MAX, t));
setDelay(lerp(DELAY.MIN, DELAY.MAX, t));
},
});

return {ref, duration, delay};
}

const MESSAGE_INTERVAL_MS = 10_000;

function useMessageCycler(messages: React.ReactNode[]) {
const [index, setIndex] = useState(0);

useEffect(() => {
if (messages.length <= 1 || index >= messages.length - 1) {
return undefined;
}
const timer = setTimeout(() => setIndex(i => i + 1), MESSAGE_INTERVAL_MS);
return () => clearTimeout(timer);
}, [index, messages.length]);

return {message: messages.length > 0 ? messages[index] : null, index};
}

export function IndeterminateLoader({
variant = 'vibrant',
messages,
...props
}: IndeterminateLoaderProps) {
const theme = useTheme();
const {ref, duration, delay} = useAnimationTiming();
const {message: currentMessage, index: messageIndex} = useMessageCycler(messages ?? []);

const track = (
<Track
ref={ref}
role="progressbar"
aria-label="Loading"
opacity={variant === 'monochrome' ? '0.2' : '1'}
color={variant === 'monochrome' ? 'currentColor' : theme.tokens.border.secondary}
{...props}
>
<ColorMask>
<Bar
color={
variant === 'monochrome' ? 'currentColor' : theme.tokens.border.accent.vibrant
}
animation={indeterminateSlow}
timing="cubic-bezier(0.4, 0.0, 0.2, 1)"
duration={`${duration}s`}
delay="0s"
/>
<Bar
color={
variant === 'monochrome' ? 'currentColor' : theme.tokens.border.accent.vibrant
}
animation={indeterminateFast}
timing="cubic-bezier(0.4, 0.0, 0.2, 1)"
duration={`${duration}s`}
delay={`${delay}s`}
/>
</ColorMask>
</Track>
);

if (!messages?.length) {
return track;
}

return (
<Stack align="start" gap="xl" width="100%" maxWidth="72ch">
{track}
<AnimatePresence mode="wait">
<motion.div
key={messageIndex}
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
transition={testableTransition({duration: 0.3})}
>
<Text monospace variant="muted" size="lg">
{currentMessage}
<Ellipsis />
</Text>
</motion.div>
</AnimatePresence>
</Stack>
);
}

const dotFadeInOut = keyframes`
0%, 30% { opacity: 0; }
40%, 70% { opacity: 1; }
80%, 100% { opacity: 0; }
`;

function Ellipsis() {
return (
<span aria-hidden>
<Dot delay={0}>.</Dot>
<Dot delay={0.2}>.</Dot>
<Dot delay={0.4}>.</Dot>
</span>
);
}

const Dot = styled('span')<{delay: number}>`
opacity: 0;
animation: ${dotFadeInOut} 2.5s ${p => p.delay}s infinite;
`;

const Track = styled('div')<{color: string; opacity: string}>`
position: relative;
overflow: hidden;
width: 100%;
width: calc(round(down, 100% - 16px, 8px) + 16px);
height: 8px;

&::before {
content: '';
position: absolute;
inset: 0;
background: ${p => p.color};
opacity: ${p => p.opacity};
mask-image: ${SQUIGGLE_TILE};
mask-repeat: repeat-x;
mask-size: 16px 8px;
-webkit-mask-image: ${SQUIGGLE_TILE};
-webkit-mask-repeat: repeat-x;
-webkit-mask-size: 16px 8px;
}
`;

const ColorMask = styled('span')`
position: absolute;
inset: 0;
mask-image: ${SQUIGGLE_TILE};
mask-repeat: repeat-x;
mask-size: 16px 8px;
-webkit-mask-image: ${SQUIGGLE_TILE};
-webkit-mask-repeat: repeat-x;
-webkit-mask-size: 16px 8px;
`;

const Bar = styled('span')<{
animation: ReturnType<typeof keyframes>;
color: string;
delay: string;
duration: string;
timing: string;
}>`
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: ${p => p.color};
animation: ${p => p.animation} ${p => p.duration} ${p => p.timing} ${p => p.delay}
infinite backwards;
`;
1 change: 1 addition & 0 deletions static/app/components/core/loader/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {IndeterminateLoader} from './indeterminateLoader';
Loading
Loading