diff --git a/packages/demo/src/components/demo/bar-graph.tsx b/packages/demo/src/components/demo/bar-graph.tsx new file mode 100644 index 0000000..19a9eb3 --- /dev/null +++ b/packages/demo/src/components/demo/bar-graph.tsx @@ -0,0 +1,145 @@ +import { BarGraph, BarGraphSegment } from "@eqtylab/equality"; + +export function BarGraphDemo() { + return ( +
+ + + + + +
+ ); +} + +const controlsSegments = () => [ + , + , + , +]; + +export function BarGraphSizeSmDemo() { + return ( +
+ {controlsSegments()} +
+ ); +} + +export function BarGraphSizeMdDemo() { + return ( +
+ {controlsSegments()} +
+ ); +} + +export function BarGraphSizeLgDemo() { + return ( +
+ {controlsSegments()} +
+ ); +} + +export function BarGraphWithLabelsDemo() { + return ( +
+ + + + + +
+ ); +} + +export function BarGraphRichTooltipDemo() { + return ( +
+ + + Success +

10 controls

+
+ } + /> + + Pending +

290 controls

+ + } + /> + + Failure +

8 controls

+ + } + /> + + + ); +} diff --git a/packages/demo/src/content/components/bar-graph.mdx b/packages/demo/src/content/components/bar-graph.mdx new file mode 100644 index 0000000..6ad5c11 --- /dev/null +++ b/packages/demo/src/content/components/bar-graph.mdx @@ -0,0 +1,114 @@ +--- +layout: "@demo/layouts/mdx-layout.astro" +heading: "Bar Graph" +description: "Single line segmented bar graph with segment tooltips" +--- + +import { + BarGraphDemo, + BarGraphSizeSmDemo, + BarGraphSizeMdDemo, + BarGraphSizeLgDemo, + BarGraphWithLabelsDemo, + BarGraphRichTooltipDemo, +} from "@demo/components/demo/bar-graph"; + +## Overview + +`BarGraph` shows a single horizontal bar split into colored segments. Use it to break a total down into its parts, such as controls status. + +Add a `BarGraphSegment` for each part. Widths come from each segment's `value` as a share of the total (`value / sum of all values`), so you pass raw numbers rather than percentages. Children that aren't a `BarGraphSegment` are ignored. + +Segments are focusable and show a [Tooltip](tooltip) on hover and focus, so they work by keyboard alone. Each segment is exposed individually to screen readers, with its `tooltip` as the accessible name — there's no single label for the bar as a whole, so screen reader users read it one segment at a time. Write each `tooltip` to describe its segment on its own. + +## Usage + +Import the component: + +```tsx +import { BarGraph, BarGraphSegment } from "@eqtylab/equality"; +``` + +Basic usage with required properties: + +```tsx + + + + + +``` + +## Variants + +### Default + +There's no visible legend by default — each segment's accessible name comes from its tooltip. Hover or focus a segment to reveal it. + + + +### Sizes + +Use `size` to control the bar's height. `sm` is a thin pill; the default `md` and `lg` are bolder and use rounded rectangles instead of a full pill. Note the small `Failure` segment, which is floored at 2px so it stays visible and focusable at every size. + +#### Small + + + +#### Medium (default) + + + +#### Large + + + +```tsx +{/* segments */} +``` + +### With visible labels + +Set `showLabels` to render a legend beneath the bar. Note that this legend will not be read aloud by screen readers, content inside it should also be exposed in the section tooltip. + + + +### Rich tooltip content + +The `tooltip` prop accepts a string or any `ReactNode`, so segments can show formatted content. + + + +## Props + +### `BarGraph` + +| Name | Description | Type | Default | Required | +| ------------ | ------------------------------------------------------------------------ | ----------------- | ------- | -------- | +| `size` | Bar height. `md` and `lg` use rounded rectangles instead of a full pill. | `sm`, `md`, `lg` | `md` | ❌ | +| `showLabels` | Render a visible legend (color swatch + label) beneath the bar. | `boolean` | `false` | ❌ | +| `children` | One or more segments to render. | `BarGraphSegment` | — | ✅ | + +### `BarGraphSegment` + +| Name | Description | Type | Default | Required | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------- | ------- | -------- | +| `value` | Sets the segment's width as a share of the total (`value / sum of all values`). Every segment is floored at 2px so small and zero values stay visible. | `number` | — | ✅ | +| `color` | Any valid CSS color (e.g. a token like `var(--color-brand-green)` or `#10b981`). | `string` | — | ✅ | +| `label` | Visible legend label, shown when `showLabels` is set. Not announced to screen readers. | `string` | — | ✅ | +| `tooltip` | Tooltip content shown on hover and keyboard focus; also the segment's accessible name, so it must describe the segment on its own. | `string \| ReactNode` | — | ✅ | diff --git a/packages/ui/package.json b/packages/ui/package.json index 9c0d810..436a533 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@eqtylab/equality", "description": "EQTYLab's component and token-based design system", "homepage": "https://equality.eqtylab.io/", - "version": "1.8.2", + "version": "1.9.0", "license": "Apache-2.0", "keywords": [ "component library", diff --git a/packages/ui/src/components/bar-graph/bar-graph.module.css b/packages/ui/src/components/bar-graph/bar-graph.module.css new file mode 100644 index 0000000..f3681a3 --- /dev/null +++ b/packages/ui/src/components/bar-graph/bar-graph.module.css @@ -0,0 +1,52 @@ +@reference '../../theme/theme.module.css'; + +.bar-graph { + @apply flex w-full flex-col gap-2; +} + +.bar { + @apply bg-greyscale-800; + @apply flex w-full gap-0.5 overflow-hidden; +} + +/* Size Variants */ + +.size--sm { + @apply h-2 rounded-full; +} + +.size--md { + @apply h-5 rounded-sm; +} + +.size--lg { + @apply h-8 rounded-sm; +} + +.segment { + @apply h-full; + flex: 1 1 0%; + @apply outline-none; + @apply focus-visible:ring-focus-ring focus-visible:ring-2 focus-visible:ring-inset; +} + +/* Screen-reader-only text that backs each segment's accessible name. */ +.visually-hidden { + @apply sr-only; +} + +.legend { + @apply m-0 flex list-none flex-wrap gap-x-4 gap-y-1 p-0; +} + +.legend-item { + @apply flex items-center gap-1.5; +} + +.legend-swatch { + @apply size-2.5 shrink-0 rounded-full; +} + +.legend-label { + @apply text-text-primary text-xs; +} diff --git a/packages/ui/src/components/bar-graph/bar-graph.tsx b/packages/ui/src/components/bar-graph/bar-graph.tsx new file mode 100644 index 0000000..da9739b --- /dev/null +++ b/packages/ui/src/components/bar-graph/bar-graph.tsx @@ -0,0 +1,140 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import styles from '@/components/bar-graph/bar-graph.module.css'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/tooltip/tooltip'; +import { cn } from '@/lib/utils'; + +export interface BarGraphSegmentProps extends Omit, 'color'> { + /** Proportional value used to compute the segment's width (`value / sum of all values`). */ + value: number; + /** Any valid CSS color, applied as the segment's background. */ + color: string; + /** + * Visible legend label, shown when `showLabels` is set on the parent `BarGraph`. + * Not announced to screen readers — the segment's accessible name is its `tooltip`. + */ + label: string; + /** + * Tooltip content shown on hover and keyboard focus. Also the segment's accessible + * name, so it must describe the segment on its own. Required. + */ + tooltip: React.ReactNode; +} + +const BarGraphSegment = React.forwardRef( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ({ value, color, label, tooltip, className, style, ...props }, ref) => { + const tooltipId = React.useId(); + + return ( + // Widths are proportional (`value / sum`): each segment grows by its `value` + // within the flex row, so the parent never needs to know the total. + // + // Accessibility: `tooltip` is the segment's accessible *name*, not its + // description. VoiceOver treats `aria-describedby` as an optional, delayed + // hint (only spoken when "Speak hints" is on), so a description can't be + // relied on to convey the value — the name always is. We expose it via + // `aria-labelledby` → a visually-hidden copy, and null Radix's own + // `aria-describedby` so its `role="tooltip"` announcer isn't read on top. + // This means `tooltip` is rendered twice (hidden name + visible popup); the + // duplication is the cost of a reliably-announced ReactNode name. + + +
+ +
+
+ + + {tooltip} + +
+ ); + } +); +BarGraphSegment.displayName = 'BarGraphSegment'; + +const barVariants = cva(styles['bar'], { + variants: { + size: { + sm: styles['size--sm'], + md: styles['size--md'], + lg: styles['size--lg'], + }, + }, + defaultVariants: { + size: 'md', + }, +}); + +/** + * A single `BarGraphSegment` element. False values are allowed so conditional + * rendering (`{condition && }`) keeps working. Non-segment + * children are filtered out at runtime — see `BarGraph`. + */ +type BarGraphChild = React.ReactElement | boolean | null | undefined; + +export interface BarGraphProps + extends Omit, 'children'>, VariantProps { + /** Render a visible legend (color swatch + label) beneath the bar. Defaults to `false`. */ + showLabels?: boolean; + /** Bar height. `md` and `lg` use rounded rectangles instead of a full pill. Defaults to `md`. */ + size?: 'sm' | 'md' | 'lg'; + /** One or more `BarGraphSegment` elements. */ + children: BarGraphChild | BarGraphChild[]; +} + +const BarGraph = React.forwardRef( + ({ className, children, showLabels = false, size, ...props }, ref) => { + const validChildren = React.Children.toArray(children).filter(React.isValidElement); + const segments = validChildren.filter( + (child): child is React.ReactElement => child.type === BarGraphSegment + ); + + return ( + +
+
{segments}
+ {showLabels && ( + + )} +
+
+ ); + } +); +BarGraph.displayName = 'BarGraph'; + +export { BarGraph, BarGraphSegment }; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 86ac1a1..3332d27 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -2,6 +2,7 @@ export * from './alert/alert'; export * from './alert-dialog/alert-dialog'; export * from './avatar/avatar'; export * from './badge/badge'; +export * from './bar-graph/bar-graph'; export * from './bg-gradient/bg-gradient'; export * from './button/button'; export * from './resource-badge/resource-badge';