diff --git a/__tests__/Statistic.test.tsx b/__tests__/Statistic.test.tsx new file mode 100644 index 00000000..9ae9ef61 --- /dev/null +++ b/__tests__/Statistic.test.tsx @@ -0,0 +1,318 @@ +import { fireEvent, render } from '@testing-library/react'; +import { + ReqoreContent, + ReqoreLayoutContent, + ReqoreStatistic, + ReqoreUIProvider, +} from '../src'; + +test('Renders with value', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); + expect(document.querySelector('.reqore-statistic-value')!.textContent).toBe('12345'); +}); + +test('Renders with string value', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-statistic-value')!.textContent).toBe('$12,345'); +}); + +test('Renders with label', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-statistic-label')!.textContent).toBe('Total Users'); +}); + +test('Does not render label when not provided', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic-label').length).toBe(0); +}); + +test('Renders with icon', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic-icon').length).toBe(1); +}); + +test('Renders with prefix and suffix', () => { + render( + + + + + + + + ); + + expect(document.querySelector('.reqore-statistic-prefix')!.textContent).toBe('$'); + expect(document.querySelector('.reqore-statistic-suffix')!.textContent).toBe(' USD'); +}); + +test('Does not render prefix/suffix when not provided', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic-prefix').length).toBe(0); + expect(document.querySelectorAll('.reqore-statistic-suffix').length).toBe(0); +}); + +test('Renders with trend up', () => { + render( + + + + + + + + ); + + const trend = document.querySelector('.reqore-statistic-trend'); + expect(trend).toBeTruthy(); + expect(trend!.querySelectorAll('.reqore-icon').length).toBe(1); + expect(trend!.textContent).toContain('+12%'); +}); + +test('Renders with trend down', () => { + render( + + + + + + + + ); + + const trend = document.querySelector('.reqore-statistic-trend'); + expect(trend).toBeTruthy(); + expect(trend!.textContent).toContain('-5%'); +}); + +test('Renders with trend neutral', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic-trend').length).toBe(1); +}); + +test('Does not render trend when not provided', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic-trend').length).toBe(0); +}); + +test('Renders with different sizes', () => { + render( + + + + + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(5); +}); + +test('Renders with intents', () => { + render( + + + + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(4); +}); + +test('Renders disabled', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); +}); + +test('Renders with vertical layout', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); + expect(document.querySelectorAll('.reqore-statistic-icon').length).toBe(1); + expect(document.querySelectorAll('.reqore-statistic-label').length).toBe(1); +}); + +test('Renders with fluid width', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); +}); + +test('Renders with rounded background', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); + expect(document.querySelector('.reqore-statistic-label')!.textContent).toBe('Users'); +}); + +test('Renders with background effect', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); +}); + +test('Renders with flat background', () => { + render( + + + + + + + + ); + + expect(document.querySelectorAll('.reqore-statistic').length).toBe(1); +}); + +test('Renders as interactive when onClick is provided', () => { + const handleClick = jest.fn(); + + render( + + + + + + + + ); + + const statistic = document.querySelector('.reqore-statistic')!; + fireEvent.click(statistic); + expect(handleClick).toHaveBeenCalledTimes(1); +}); + diff --git a/package.json b/package.json index c946ac9c..0dd3b6d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqore", - "version": "0.59.1", + "version": "0.60.0", "description": "ReQore is a highly theme-able and modular UI library for React", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/components/ControlGroup/index.tsx b/src/components/ControlGroup/index.tsx index ced54982..c502836c 100644 --- a/src/components/ControlGroup/index.tsx +++ b/src/components/ControlGroup/index.tsx @@ -134,9 +134,23 @@ export const StyledReqoreControlGroup = styled(StyledEffect) * { - border-radius: ${({ stack }) => (!stack ? undefined : '0')}; - } + ${({ stack, wrap }) => { + if (!stack) return undefined; + + return css` + ${wrap ? 'padding: 1px 0 0 1px;' : ''} + + > * { + border-radius: 0; + position: relative; + + &:hover, + &:focus-within { + z-index: 1; + } + } + `; + }} `; const ReqoreControlGroup = memo( @@ -395,9 +409,24 @@ const ReqoreControlGroup = memo( }; if (isStack) { + const childIsFlat = props?.flat || props?.flat === false ? props.flat : flat; + const needsCollapse = !childIsFlat; + const isFirstChild = index === 0; + + const collapseMargin = needsCollapse + ? wrap + ? { marginTop: -1, marginLeft: -1 } + : !isFirstChild + ? isVertical + ? { marginTop: -1 } + : { marginLeft: -1 } + : {} + : {}; + newProps = { ...newProps, style: { + ...collapseMargin, borderTopLeftRadius: getBorderTopLeftRadius(index, props?.rounded), borderBottomLeftRadius: getBorderBottomLeftRadius(index, props?.rounded), borderTopRightRadius: getBorderTopRightRadius(index, props?.rounded), @@ -442,6 +471,7 @@ const ReqoreControlGroup = memo( fill, customTheme, isMasterGroupRounded, + wrap, ] ); diff --git a/src/components/Statistic/index.tsx b/src/components/Statistic/index.tsx new file mode 100644 index 00000000..fa0882ad --- /dev/null +++ b/src/components/Statistic/index.tsx @@ -0,0 +1,320 @@ +import { rgba } from 'polished'; +import { forwardRef, memo, useMemo } from 'react'; +import styled, { css } from 'styled-components'; +import { PADDING_FROM_SIZE, RADIUS_FROM_SIZE, TSizes } from '../../constants/sizes'; +import { IReqoreTheme, TReqoreIntent } from '../../constants/theme'; +import { + changeDarkness, + changeLightness, + getMainBackgroundColor, + getReadableColor, +} from '../../helpers/colors'; +import { alignToFlexAlign, getOneHigherSize, getOneLessSize } from '../../helpers/utils'; +import { useReqoreTheme } from '../../hooks/useTheme'; +import { DisabledElement, InactiveIconScale, ScaleIconOnHover } from '../../styles'; +import { + IReqoreDisabled, + IReqoreIntent, + IWithReqoreCustomTheme, + IWithReqoreFlat, + IWithReqoreFluid, + IWithReqoreSize, + IWithReqoreTooltip, +} from '../../types/global'; +import { IReqoreIconName } from '../../types/icons'; +import ReqoreControlGroup from '../ControlGroup'; +import { IReqoreEffect, StyledEffect, TReqoreEffectColor } from '../Effect'; +import { ReqoreHeading } from '../Header'; +import ReqoreIcon, { IReqoreIconProps } from '../Icon'; +import { ReqoreSpan } from '../Span'; +import { ReqoreTooltipComponent } from '../TooltipComponent'; + +export type TReqoreStatisticTrendDirection = 'up' | 'down' | 'neutral'; + +export interface IReqoreStatisticTrend { + /** Direction of the trend */ + direction: TReqoreStatisticTrendDirection; + /** Optional text to display next to the trend arrow (e.g., "+12%") */ + value?: string | number; + /** Override the automatic intent color for the trend */ + intent?: TReqoreIntent; + /** Override the default arrow icon */ + icon?: IReqoreIconName; +} + +export interface IReqoreStatisticProps + extends Omit, 'children'>, + IReqoreDisabled, + IReqoreIntent, + IWithReqoreCustomTheme, + IWithReqoreFluid, + IWithReqoreFlat, + IWithReqoreSize, + IWithReqoreTooltip { + /** The primary value to display */ + value: string | number; + /** Optional label displayed above the value */ + label?: string; + /** Optional icon */ + icon?: IReqoreIconName; + /** Optional color for the icon */ + iconColor?: TReqoreEffectColor; + /** Additional icon props */ + iconProps?: Omit; + /** Text prepended to the value (e.g., "$") */ + prefix?: string; + /** Text appended to the value (e.g., "%") */ + suffix?: string; + /** Trend indicator configuration */ + trend?: IReqoreStatisticTrend; + /** Effect applied to the value text */ + valueEffect?: IReqoreEffect; + /** Effect applied to the label text */ + labelEffect?: IReqoreEffect; + /** Effect applied to the card background (supports gradients) */ + effect?: IReqoreEffect; + /** Text alignment */ + align?: 'left' | 'center' | 'right'; + /** Rounded border corners */ + rounded?: boolean; + /** Transparent background */ + transparent?: boolean; + /** Background opacity */ + opacity?: number; +} + +interface IStyledStatisticWrapper { + theme: IReqoreTheme; + size: TSizes; + $fluid?: boolean; + disabled?: boolean; + $hasBackground?: boolean; + $interactive?: boolean; + $align?: 'flex-start' | 'center' | 'flex-end'; + rounded?: boolean; + flat?: boolean; + intent?: string; + opacity?: number; +} + +const TREND_ICONS: Record = { + up: 'ArrowUpSLine', + down: 'ArrowDownSLine', + neutral: 'SubtractLine', +}; + +const TREND_DEFAULT_INTENTS: Record = { + up: 'success', + down: 'danger', + neutral: 'muted', +}; + +const StyledStatisticWrapper = styled(StyledEffect)` + display: inline-flex; + justify-content: ${({ $align }) => $align}; + width: ${({ $fluid }) => ($fluid ? '100%' : undefined)}; + + ${({ $hasBackground, theme, size, rounded, flat, intent, opacity = 1 }) => + $hasBackground && + css` + background-color: ${rgba(changeDarkness(getMainBackgroundColor(theme), 0.03), opacity)}; + border-radius: ${rounded ? RADIUS_FROM_SIZE[size] : 0}px; + border: ${flat + ? undefined + : `1px solid ${changeLightness( + intent ? theme.intents[intent] : getMainBackgroundColor(theme), + 0.08 + )}`}; + color: ${getReadableColor(theme, undefined, undefined, true)}; + padding: ${PADDING_FROM_SIZE[size] * 3}px ${PADDING_FROM_SIZE[size] * 5}px; + `} + + ${({ $interactive, disabled }) => + $interactive && !disabled + ? css` + ${InactiveIconScale}; + ${ScaleIconOnHover}; + cursor: pointer; + transition: all 0.2s ease-out; + + &:active { + transform: scale(0.98); + } + ` + : undefined} + + ${({ disabled }) => + disabled && + css` + ${DisabledElement}; + `} +`; + +const StyledStatisticValueRow = styled.div` + display: flex; + align-items: baseline; + gap: 4px; + flex-wrap: nowrap; +`; + +const ReqoreStatistic = memo( + forwardRef( + ( + { + value, + label, + icon, + iconColor, + iconProps, + prefix, + suffix, + trend, + valueEffect, + labelEffect, + effect, + align = 'center', + size = 'normal', + customTheme, + intent, + fluid, + flat, + disabled, + tooltip, + rounded, + transparent, + opacity, + className, + ...rest + }, + ref + ) => { + const theme = useReqoreTheme('main', customTheme, intent); + + const secondarySize = useMemo(() => getOneLessSize(size), [size]); + const flexAlign = useMemo(() => alignToFlexAlign(align), [align]); + + const interactive = useMemo( + () => !!(rest.onClick || rest.onDoubleClick || rest.onContextMenu), + [rest.onClick, rest.onDoubleClick, rest.onContextMenu] + ); + + const hasBackground = useMemo( + () => !!(effect || rounded || flat !== undefined || transparent || opacity !== undefined), + [effect, rounded, flat, transparent, opacity] + ); + + const trendIntent = useMemo( + () => (trend ? trend.intent || TREND_DEFAULT_INTENTS[trend.direction] : undefined), + [trend] + ); + + const trendIcon = useMemo( + () => (trend ? trend.icon || TREND_ICONS[trend.direction] : undefined), + [trend] + ); + + const transformedEffect: IReqoreEffect = useMemo(() => { + if (!effect) return undefined; + + const newEffect: IReqoreEffect = { ...effect }; + + if (newEffect.gradient && intent) { + newEffect.gradient.borderColor = theme.intents[intent]; + } + + return newEffect; + }, [effect, intent, theme]); + + return ( + + + {icon && ( + + )} + {label && ( + + {label} + + )} + + {prefix && ( + + {prefix} + + )} + + {value} + + {suffix && ( + + {suffix} + + )} + + {trend && ( + + + {trend.value !== undefined && ( + + {trend.value} + + )} + + )} + + + ); + } + ) +); + +export default ReqoreStatistic; diff --git a/src/index.tsx b/src/index.tsx index 851288e0..c9387310 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -57,6 +57,7 @@ export { ReqoreSlider } from './components/Slider'; export { ReqoreHorizontalSpacer, ReqoreSpacer, ReqoreVerticalSpacer } from './components/Spacer'; export { ReqoreSpan } from './components/Span'; export { ReqoreSpinner } from './components/Spinner'; +export { default as ReqoreStatistic } from './components/Statistic'; export { default as ReqoreTable } from './components/Table'; export { ReqoreTableBodyCell } from './components/Table/cell'; export { ReqoreTableHeaderCell } from './components/Table/headerCell'; diff --git a/src/stories/ControlGroup/ControlGroup.stories.tsx b/src/stories/ControlGroup/ControlGroup.stories.tsx index 5b59f769..230c4020 100644 --- a/src/stories/ControlGroup/ControlGroup.stories.tsx +++ b/src/stories/ControlGroup/ControlGroup.stories.tsx @@ -357,6 +357,39 @@ export const Stacked: Story = { }, }; +export const StackedButtonsOnly: Story = { + render: () => { + return ( + + First + Second + Third + Fourth + Fifth + Sixth + Seventh + Eighth + Ninth + Tenth + Eleventh + Twelfth + Thirteenth + Fourteenth + Fifteenth + Sixteenth + Seventeenth + Eighteenth + Nineteenth + Twentieth + + ); + }, + + args: { + stack: true, + }, +}; + export const BigGapSize: Story = { render: Template, @@ -397,3 +430,61 @@ export const Responsive: Story = { responsive: true, }, }; + +export const StackedBorders: Story = { + render: (args) => ( + + First + Second + Third + Fourth + + ), +}; + +export const StackedBordersVertical: Story = { + render: (args) => ( + + First + Second + Third + + ), +}; + +export const StackedBordersWithIntents: Story = { + render: (args) => ( + + Default + + Info + + + Success + + + Warning + + + Danger + + + ), +}; + +export const StackedBordersWrapping: Story = { + render: (args) => ( +
+ + One + Two + Three + Four + Five + Six + Seven + Eight + +
+ ), +}; diff --git a/src/stories/Statistic/Statistic.stories.tsx b/src/stories/Statistic/Statistic.stories.tsx new file mode 100644 index 00000000..ad0c4b16 --- /dev/null +++ b/src/stories/Statistic/Statistic.stories.tsx @@ -0,0 +1,440 @@ +import { StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import ReqoreStatistic from '../../components/Statistic'; +import { ReqoreControlGroup } from '../../index'; +import { StoryMeta } from '../utils'; + +const meta = { + title: 'Data Display/Statistic/Stories', + component: ReqoreStatistic, +} as StoryMeta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + value: 12345, + }, +}; + +export const WithLabel: Story = { + render: (args) => ( + + + + + + ), +}; + +export const WithIcon: Story = { + render: (args) => ( + + + + + + ), +}; + +export const WithPrefixAndSuffix: Story = { + render: (args) => ( + + + + + + + ), +}; + +export const WithTrend: Story = { + render: (args) => ( + + + + + + ), +}; + +export const Sizes: Story = { + render: (args) => { + const sizes = ['tiny', 'small', 'normal', 'big', 'huge'] as const; + + return ( + + {sizes.map((size) => ( + + ))} + + ); + }, +}; + +export const Intents: Story = { + render: (args) => ( + + + + + + + + + + ), +}; + +export const Alignment: Story = { + render: (args) => ( + +
+ +
+
+ +
+
+ +
+
+ ), +}; + +export const WithValueEffects: Story = { + render: (args) => ( + + + + + ), +}; + +export const WithBackground: Story = { + render: (args) => ( + + + + + + + ), +}; + +export const WithBackgroundFlat: Story = { + render: (args) => ( + + + + + + ), +}; + +export const WithGradientBackground: Story = { + render: (args) => ( + + + + + + ), +}; + +export const Disabled: Story = { + render: (args) => ( + + + + + ), +}; + +export const Interactive: Story = { + render: (args) => { + const [selected, setSelected] = useState(null); + + return ( + + setSelected('users')} + /> + setSelected('revenue')} + /> + setSelected('sessions')} + /> + setSelected('disabled')} + /> + + ); + }, +}; + +export const DashboardExample: Story = { + render: (args) => ( + + + + + + + ), +}; + +export const StackedStatistics: Story = { + render: (args) => ( + + + + + + + + ), +};