Skip to content

Commit 709c9ff

Browse files
authored
Merge pull request #580 from qoretechnologies/bugfix/remove-chromatic
Add corner indicator dot to ReqoreButton
2 parents 023e9cf + c2ad499 commit 709c9ff

7 files changed

Lines changed: 374 additions & 5 deletions

File tree

__tests__/button.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,52 @@ test('Animated <Button /> exposes one accessible label', () => {
4545
expect(screen.getByRole('button', { name: 'Animated label' })).toBeTruthy();
4646
});
4747

48+
test('Renders <Button /> indicator when enabled', () => {
49+
render(
50+
<ReqoreUIProvider>
51+
<ReqoreLayoutContent>
52+
<ReqoreContent>
53+
<ReqoreButton indicator>With indicator</ReqoreButton>
54+
<ReqoreButton>Plain</ReqoreButton>
55+
</ReqoreContent>
56+
</ReqoreLayoutContent>
57+
</ReqoreUIProvider>
58+
);
59+
60+
expect(document.querySelectorAll('.reqore-button-indicator').length).toBe(1);
61+
});
62+
63+
test('Renders <Button /> indicator with a custom intent', () => {
64+
render(
65+
<ReqoreUIProvider>
66+
<ReqoreLayoutContent>
67+
<ReqoreContent>
68+
<ReqoreButton indicator={{ intent: 'success', pulse: true }}>Success dot</ReqoreButton>
69+
</ReqoreContent>
70+
</ReqoreLayoutContent>
71+
</ReqoreUIProvider>
72+
);
73+
74+
const indicator = document.querySelector('.reqore-button-indicator');
75+
76+
expect(indicator).toBeTruthy();
77+
expect(window.getComputedStyle(indicator!).backgroundColor).toBeTruthy();
78+
});
79+
80+
test('Does not render <Button /> indicator by default', () => {
81+
render(
82+
<ReqoreUIProvider>
83+
<ReqoreLayoutContent>
84+
<ReqoreContent>
85+
<ReqoreButton>No indicator</ReqoreButton>
86+
</ReqoreContent>
87+
</ReqoreLayoutContent>
88+
</ReqoreUIProvider>
89+
);
90+
91+
expect(document.querySelectorAll('.reqore-button-indicator').length).toBe(0);
92+
});
93+
4894
test('Renders <Button /> with size properly', () => {
4995
render(
5096
<ReqoreUIProvider>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@qoretechnologies/reqore",
3-
"version": "0.70.4",
3+
"version": "0.70.5",
44
"description": "ReQore is a highly theme-able and modular UI library for React",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/components/Button/index.tsx

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@ import { size } from 'lodash';
22
import { rgba, saturate, tint } from 'polished';
33
import React, { forwardRef, memo, useCallback, useMemo } from 'react';
44
import { useHotkeys } from 'react-hotkeys-hook';
5-
import styled, { css } from 'styled-components';
5+
import styled, { css, keyframes } from 'styled-components';
66
import { CONTROL_ICON_OPACITY } from '../../constants/colors';
77
import {
88
CONTROL_HORIZONTAL_PADDING_FROM_SIZE,
99
CONTROL_TEXT_FROM_SIZE,
1010
CONTROL_VERTICAL_PADDING_FROM_SIZE,
1111
CONTROL_VERTICAL_PADDING_MODIFIER_FROM_SIZE,
1212
ICON_FROM_SIZE,
13+
INDICATOR_SIZE_TO_PX,
1314
PADDING_FROM_SIZE,
1415
PILL_RADIUS_MODIFIER,
1516
resolveRadius,
1617
SIZE_TO_PX,
1718
TSizes,
1819
} from '../../constants/sizes';
19-
import { IReqoreCustomTheme, IReqoreTheme } from '../../constants/theme';
20+
import { IReqoreCustomTheme, IReqoreTheme, TReqoreIntent } from '../../constants/theme';
2021
import {
2122
changeLightness,
2223
getGradientMix,
@@ -57,6 +58,7 @@ import {
5758
IReqoreEffect,
5859
ReqoreTextEffect,
5960
StyledEffect,
61+
TReqoreColor,
6062
TReqoreEffectColor,
6163
TReqoreHexColor,
6264
} from '../Effect';
@@ -69,6 +71,78 @@ import { ReqoreTooltipComponent } from '../TooltipComponent';
6971

7072
export type TReqoreBadge = string | number | IReqoreTagProps;
7173

74+
export type TReqoreButtonIndicatorPosition =
75+
| 'top-right'
76+
| 'top-left'
77+
| 'bottom-right'
78+
| 'bottom-left';
79+
80+
export interface IReqoreButtonIndicatorProps {
81+
/** Intent used to tint the dot. Resolved from the button's theme intents. Defaults to `danger`. */
82+
intent?: TReqoreIntent;
83+
/** Explicit color override. Takes precedence over `intent`. */
84+
color?: TReqoreColor;
85+
/** Animate the dot with an expanding, fading pulse ring. Defaults to `false` (static). */
86+
pulse?: boolean;
87+
/** Corner the dot is anchored to. Defaults to `top-right`. */
88+
position?: TReqoreButtonIndicatorPosition;
89+
}
90+
91+
const indicatorPulseKeyframes = keyframes`
92+
0% {
93+
transform: scale(1);
94+
opacity: 0.55;
95+
}
96+
70% {
97+
opacity: 0;
98+
}
99+
100% {
100+
transform: scale(2.6);
101+
opacity: 0;
102+
}
103+
`;
104+
105+
export interface IReqoreButtonIndicatorStyle {
106+
$color: TReqoreColor;
107+
$diameter: number;
108+
$offset: number;
109+
$position: TReqoreButtonIndicatorPosition;
110+
$pulse?: boolean;
111+
}
112+
113+
export const StyledButtonIndicator = styled.span<IReqoreButtonIndicatorStyle>`
114+
position: absolute;
115+
z-index: 2;
116+
width: ${({ $diameter }) => $diameter}px;
117+
height: ${({ $diameter }) => $diameter}px;
118+
border-radius: 50%;
119+
background-color: ${({ $color }) => $color};
120+
pointer-events: none;
121+
122+
${({ $position, $offset }) => {
123+
const [vertical, horizontal] = $position.split('-');
124+
125+
return css`
126+
${vertical}: ${$offset}px;
127+
${horizontal}: ${$offset}px;
128+
`;
129+
}}
130+
131+
${({ $pulse, $color }) =>
132+
$pulse
133+
? css`
134+
&::after {
135+
content: '';
136+
position: absolute;
137+
inset: 0;
138+
border-radius: 50%;
139+
background-color: ${$color};
140+
animation: ${indicatorPulseKeyframes} 1.6s ease-out infinite;
141+
}
142+
`
143+
: undefined};
144+
`;
145+
72146
/**
73147
* Button props for the primary Reqore action component.
74148
*/
@@ -99,6 +173,17 @@ export interface IReqoreButtonProps
99173
wrap?: boolean;
100174
badge?: TReqoreBadge | TReqoreBadge[];
101175

176+
/**
177+
* Renders a small dot in a corner of the button to signal importance or a
178+
* pending event (unread items, a required action, a live status). Distinct
179+
* from `badge`, which renders content tags inside the button.
180+
*
181+
* Pass `true` for a static, danger-tinted dot in the top-right corner, or an
182+
* object to customize the `intent`/`color`, enable the `pulse` animation, and
183+
* pick the `position`.
184+
*/
185+
indicator?: boolean | IReqoreButtonIndicatorProps;
186+
102187
/**
103188
* Keyboard shortcut that triggers the button's `onClick`, following the
104189
* `react-hotkeys-hook` syntax (e.g. `'mod+k'`, `'ctrl+shift+s'`, or an array
@@ -514,6 +599,7 @@ const ReqoreButton = memo(
514599
wrap,
515600
readOnly,
516601
badge,
602+
indicator,
517603
description,
518604
maxWidth,
519605
textAlign = 'left',
@@ -598,6 +684,23 @@ const ReqoreButton = memo(
598684
[fixedEffect, intent, readOnly, rest.disabled]
599685
);
600686

687+
const indicatorConfig = useMemo(() => {
688+
if (!indicator) {
689+
return undefined;
690+
}
691+
692+
const config: IReqoreButtonIndicatorProps = indicator === true ? {} : indicator;
693+
const diameter = INDICATOR_SIZE_TO_PX[size];
694+
695+
return {
696+
color: config.color ?? theme.intents?.[config.intent ?? 'danger'] ?? theme.intents?.danger,
697+
diameter,
698+
offset: Math.round(diameter * 0.6),
699+
position: config.position ?? 'top-right',
700+
pulse: config.pulse ?? false,
701+
};
702+
}, [indicator, size, theme.intents]);
703+
601704
return (
602705
<ReqoreTooltipComponent
603706
{...{
@@ -785,6 +888,18 @@ const ReqoreButton = memo(
785888
{description}
786889
</ReqoreTextEffect>
787890
)}
891+
892+
{indicatorConfig && (
893+
<StyledButtonIndicator
894+
className='reqore-button-indicator'
895+
$color={indicatorConfig.color}
896+
$diameter={indicatorConfig.diameter}
897+
$offset={indicatorConfig.offset}
898+
$position={indicatorConfig.position}
899+
$pulse={indicatorConfig.pulse}
900+
aria-hidden='true'
901+
/>
902+
)}
788903
</ReqoreTooltipComponent>
789904
);
790905
}

src/components/Popover/index.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@ import { useCombinedRefs } from '../../hooks/useCombinedRefs';
88
import {
99
IReqoreComponent,
1010
IReqoreIntent,
11+
IWithReqoreCustomTheme,
1112
IWithReqoreEffect,
1213
IWithReqoreFlat,
1314
IWithReqoreMinimal,
1415
} from '../../types/global';
1516
import { IReqoreIconName } from '../../types/icons';
1617
import InternalPopover from '../InternalPopover';
1718

18-
export interface IReqorePopoverProps extends IReqoreComponent, IPopoverOptions {
19+
export interface IReqorePopoverProps
20+
extends IReqoreComponent,
21+
IWithReqoreCustomTheme,
22+
IPopoverOptions {
1923
component: any;
2024
componentProps?: any;
2125
children?: any;
@@ -139,6 +143,18 @@ export const ReqorePopover = memo(
139143
intent,
140144
id,
141145
backgroundBlur,
146+
// `customTheme` is picked up so it can be forwarded to the
147+
// trigger `Component` below. Historically the popover's own
148+
// props were dropped on the floor, which meant a Popover
149+
// rendered inside a panel/action slot with a themed context
150+
// (e.g. an accent-themed toolbar) did NOT paint its trigger
151+
// with the surrounding theme — the sibling buttons inherited
152+
// the theme via `<Component customTheme={theme} />` in Panel,
153+
// but Popover swallowed it. Forwarding it here lets Popover
154+
// participate in the same theme cascade as every other
155+
// panel-action item without callers having to duplicate the
156+
// theme on `componentProps`.
157+
customTheme,
142158
}: IReqorePopoverProps,
143159
ref
144160
) => {
@@ -477,6 +493,10 @@ export const ReqorePopover = memo(
477493
)}
478494
{isOpen && blur ? <div className='reqore-blur-wrapper' /> : null}
479495
<Component
496+
// Forward the popover's own `customTheme` to the trigger
497+
// so it inherits the surrounding theme cascade. Caller
498+
// wins if they already set one on `componentProps`.
499+
customTheme={componentProps?.customTheme ?? customTheme}
480500
{...componentProps}
481501
className={`${isOpen && blur ? 'reqore-blur-z-index' : ''} ${
482502
componentProps?.className || ''
@@ -528,7 +548,16 @@ export const ReqorePopover = memo(
528548
ref={handleRef}
529549
style={wrapperStyle}
530550
>
531-
<Component {...componentProps}>{children}</Component>
551+
<Component
552+
// See comment on the mirroring path above — forward the
553+
// popover's own `customTheme` to the trigger so it
554+
// inherits the surrounding theme cascade. Caller wins
555+
// if they already set one on `componentProps`.
556+
customTheme={componentProps?.customTheme ?? customTheme}
557+
{...componentProps}
558+
>
559+
{children}
560+
</Component>
532561
</StyledPopover>
533562
</>
534563
);

src/constants/sizes.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ export const BADGE_SIZE_TO_PX = {
105105
massive: 44,
106106
};
107107

108+
/** Diameter (px) of the button corner indicator dot per size. */
109+
export const INDICATOR_SIZE_TO_PX = {
110+
micro: 5,
111+
tiny: 6,
112+
small: 7,
113+
normal: 8,
114+
big: 10,
115+
huge: 12,
116+
massive: 14,
117+
};
118+
108119
export const TABS_SIZE_TO_PX = {
109120
micro: 16,
110121
tiny: 20,

src/stories/Button/Button.stories.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,70 @@ export const Shortcut: Story = {
605605
},
606606
};
607607

608+
export const Indicator: Story = {
609+
render: () => (
610+
<ReqoreControlGroup vertical gapSize='big'>
611+
<ReqoreControlGroup wrap>
612+
<ReqoreButton icon='NotificationLine' indicator>
613+
Default (danger)
614+
</ReqoreButton>
615+
<ReqoreButton icon='NotificationLine' indicator={{ intent: 'info' }}>
616+
Info
617+
</ReqoreButton>
618+
<ReqoreButton icon='NotificationLine' indicator={{ intent: 'success' }}>
619+
Success
620+
</ReqoreButton>
621+
<ReqoreButton icon='NotificationLine' indicator={{ intent: 'warning' }}>
622+
Warning
623+
</ReqoreButton>
624+
<ReqoreButton icon='NotificationLine' indicator={{ color: '#a24bff' }}>
625+
Custom color
626+
</ReqoreButton>
627+
</ReqoreControlGroup>
628+
629+
<ReqoreControlGroup wrap>
630+
<ReqoreButton icon='NotificationLine' indicator={{ intent: 'danger', pulse: true }}>
631+
Pulsing danger
632+
</ReqoreButton>
633+
<ReqoreButton icon='NotificationLine' indicator={{ intent: 'success', pulse: true }}>
634+
Pulsing success
635+
</ReqoreButton>
636+
<ReqoreButton icon='NotificationLine' minimal indicator={{ intent: 'info', pulse: true }}>
637+
Pulsing minimal
638+
</ReqoreButton>
639+
</ReqoreControlGroup>
640+
641+
<ReqoreControlGroup wrap>
642+
<ReqoreButton icon='NotificationLine' indicator={{ position: 'top-right', pulse: true }}>
643+
Top right
644+
</ReqoreButton>
645+
<ReqoreButton icon='NotificationLine' indicator={{ position: 'top-left', pulse: true }}>
646+
Top left
647+
</ReqoreButton>
648+
<ReqoreButton icon='NotificationLine' indicator={{ position: 'bottom-right', pulse: true }}>
649+
Bottom right
650+
</ReqoreButton>
651+
<ReqoreButton icon='NotificationLine' indicator={{ position: 'bottom-left', pulse: true }}>
652+
Bottom left
653+
</ReqoreButton>
654+
</ReqoreControlGroup>
655+
656+
<ReqoreControlGroup wrap verticalAlign='center'>
657+
{ALL_SIZES.map((size) => (
658+
<ReqoreButton
659+
key={size}
660+
size={size}
661+
icon='NotificationLine'
662+
indicator={{ intent: 'danger', pulse: true }}
663+
>
664+
{size}
665+
</ReqoreButton>
666+
))}
667+
</ReqoreControlGroup>
668+
</ReqoreControlGroup>
669+
),
670+
};
671+
608672
export const MultipleGradients: Story = {
609673
render: () => (
610674
<ReqoreControlGroup wrap>

0 commit comments

Comments
 (0)