diff --git a/apps/www/src/content/docs/components/meter/demo.ts b/apps/www/src/content/docs/components/meter/demo.ts new file mode 100644 index 00000000..fe373259 --- /dev/null +++ b/apps/www/src/content/docs/components/meter/demo.ts @@ -0,0 +1,140 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: any) => { + return ``; +}; + +export const playground = { + type: 'playground', + controls: { + value: { type: 'number', initialValue: 40, min: 0, max: 100 }, + variant: { + type: 'select', + initialValue: 'linear', + options: ['linear', 'circular'] + }, + min: { type: 'number', defaultValue: 0, min: 0, max: 99 }, + max: { type: 'number', defaultValue: 100, min: 1, max: 100 } + }, + getCode +}; + +export const directUsageDemo = { + type: 'code', + code: ` + + + +` +}; + +export const variantDemo = { + type: 'code', + tabs: [ + { + name: 'Linear', + code: ` + + + Storage used + + + + +` + }, + { + name: 'Circular', + code: ` + + + + + + + + + + + + +` + } + ] +}; + +export const customizationDemo = { + type: 'code', + tabs: [ + { + name: 'Linear', + code: ` + + + + + + + + + +` + }, + { + name: 'Circular', + code: ` + + + + + + + + + + + + + + + + +` + } + ] +}; + +export const withLabelsDemo = { + type: 'code', + code: ` + + + CPU Usage + + + + + + + Memory + + + + +` +}; + +export const customRangeDemo = { + type: 'code', + code: ` + + + API Calls + + + + +` +}; diff --git a/apps/www/src/content/docs/components/meter/index.mdx b/apps/www/src/content/docs/components/meter/index.mdx new file mode 100644 index 00000000..7e18ac50 --- /dev/null +++ b/apps/www/src/content/docs/components/meter/index.mdx @@ -0,0 +1,91 @@ +--- +title: Meter +description: A graphical display of a numeric value within a known range. +source: packages/raystack/components/meter +--- + +import { playground, directUsageDemo, variantDemo, customizationDemo, withLabelsDemo, customRangeDemo } from "./demo.ts"; + + + +## Anatomy + +Import and assemble the component: + +```tsx +import { Meter } from '@raystack/apsara' + +{/* Direct usage — renders track automatically */} + + +{/* Composable usage */} + + Storage used + + + +``` + +## API Reference + +### Root + +The main container for the meter. Renders a default track when no children are provided. + + + +### Label + +Displays a label for the meter. + + + +### Value + +Displays the formatted current value as a percentage. + + + +### Track + +Contains the indicator that visualizes the current value. + + + +## Examples + +### Variant + +The meter supports `linear` and `circular` variants. + + + +### Direct Usage + +The simplest way to use the meter. When no children are provided, it renders the track automatically. + + + +### Customization + +Customize the track for both variants. For linear, use `height` on the track. For circular, use `width`/`height` on the track to control the overall size and `--rs-meter-track-size` to control the stroke thickness. + + + +### With Labels + +Compose with `Meter.Label` and `Meter.Value` for additional context. + + + +### Custom Range + +Use `min` and `max` to define custom value ranges. + + + +## Accessibility + +- Uses the `meter` ARIA role +- Sets `aria-valuenow`, `aria-valuemin`, and `aria-valuemax` attributes +- Supports `aria-label` and `aria-valuetext` for screen readers diff --git a/apps/www/src/content/docs/components/meter/props.ts b/apps/www/src/content/docs/components/meter/props.ts new file mode 100644 index 00000000..5ca2fa4d --- /dev/null +++ b/apps/www/src/content/docs/components/meter/props.ts @@ -0,0 +1,43 @@ +export interface MeterProps { + /** The current value of the meter. */ + value: number; + + /** + * Minimum value. + * @default 0 + */ + min?: number; + + /** + * Maximum value. + * @default 100 + */ + max?: number; + + /** + * The visual style of the meter. + * @default "linear" + */ + variant?: 'linear' | 'circular'; + + /** Additional CSS class name. */ + className?: string; +} + +export interface MeterLabelProps { + /** The label text content. */ + children: React.ReactNode; + + /** Additional CSS class name. */ + className?: string; +} + +export interface MeterValueProps { + /** Additional CSS class name. */ + className?: string; +} + +export interface MeterTrackProps { + /** Additional CSS class name. */ + className?: string; +} diff --git a/packages/raystack/components/meter/__tests__/meter.test.tsx b/packages/raystack/components/meter/__tests__/meter.test.tsx new file mode 100644 index 00000000..a528ac4f --- /dev/null +++ b/packages/raystack/components/meter/__tests__/meter.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Meter } from '../meter'; +import styles from '../meter.module.css'; + +describe('Meter', () => { + describe('Basic Rendering', () => { + it('renders meter element', () => { + const { container } = render(); + const meter = container.querySelector(`.${styles.meter}`); + expect(meter).toBeInTheDocument(); + }); + + it('forwards ref correctly', () => { + const ref = vi.fn(); + render(); + expect(ref).toHaveBeenCalled(); + }); + + it('applies custom className', () => { + const { container } = render( + + ); + const meter = container.querySelector(`.${styles.meter}`); + expect(meter).toHaveClass('custom-meter'); + }); + + it('renders track and indicator by default', () => { + const { container } = render(); + expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument(); + expect( + container.querySelector(`.${styles.indicator}`) + ).toBeInTheDocument(); + }); + + it('renders default track when no children provided', () => { + const { container } = render(); + expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument(); + }); + }); + + describe('Variants', () => { + it('defaults to linear variant', () => { + const { container } = render(); + const meter = container.querySelector(`.${styles.meter}`); + expect(meter).not.toHaveClass(styles['meter-variant-circular']); + }); + + it('renders circular variant', () => { + const { container } = render(); + const meter = container.querySelector(`.${styles.meter}`); + expect(meter).toHaveClass(styles['meter-variant-circular']); + }); + + it('renders SVG track and indicator for circular variant', () => { + const { container } = render(); + expect(container.querySelector('svg')).toBeInTheDocument(); + expect( + container.querySelector(`.${styles.circularTrackCircle}`) + ).toBeInTheDocument(); + expect( + container.querySelector(`.${styles.circularIndicatorCircle}`) + ).toBeInTheDocument(); + }); + }); + + describe('Sub-components', () => { + it('renders Label sub-component', () => { + render( + + Storage + + + ); + expect(screen.getByText('Storage')).toBeInTheDocument(); + }); + + it('renders Value sub-component', () => { + render( + + + + + ); + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + + it('renders custom children instead of default track', () => { + const { container } = render( + + Custom + + + ); + expect(screen.getByText('Custom')).toBeInTheDocument(); + expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('has meter role', () => { + render(); + expect(screen.getByRole('meter')).toBeInTheDocument(); + }); + + it('sets aria-valuenow', () => { + render(); + const meter = screen.getByRole('meter'); + expect(meter).toHaveAttribute('aria-valuenow', '75'); + }); + + it('sets aria-valuemin and aria-valuemax', () => { + render(); + const meter = screen.getByRole('meter'); + expect(meter).toHaveAttribute('aria-valuemin', '0'); + expect(meter).toHaveAttribute('aria-valuemax', '200'); + }); + }); +}); diff --git a/packages/raystack/components/meter/index.tsx b/packages/raystack/components/meter/index.tsx new file mode 100644 index 00000000..313c00a5 --- /dev/null +++ b/packages/raystack/components/meter/index.tsx @@ -0,0 +1 @@ +export { Meter } from './meter'; diff --git a/packages/raystack/components/meter/meter-misc.tsx b/packages/raystack/components/meter/meter-misc.tsx new file mode 100644 index 00000000..5ccb5e85 --- /dev/null +++ b/packages/raystack/components/meter/meter-misc.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { Meter as MeterPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { type ElementRef, forwardRef } from 'react'; +import styles from './meter.module.css'; + +export const MeterLabel = forwardRef< + ElementRef, + MeterPrimitive.Label.Props +>(({ className, ...props }, ref) => ( + +)); + +MeterLabel.displayName = 'MeterLabel'; + +export const MeterValue = forwardRef< + ElementRef, + MeterPrimitive.Value.Props +>(({ className, ...props }, ref) => ( + +)); + +MeterValue.displayName = 'MeterValue'; diff --git a/packages/raystack/components/meter/meter-root.tsx b/packages/raystack/components/meter/meter-root.tsx new file mode 100644 index 00000000..f791240a --- /dev/null +++ b/packages/raystack/components/meter/meter-root.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Meter as MeterPrimitive } from '@base-ui/react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { createContext, type ElementRef, forwardRef } from 'react'; +import styles from './meter.module.css'; +import { MeterTrack } from './meter-track'; + +const meter = cva(styles.meter, { + variants: { + variant: { + linear: '', + circular: styles['meter-variant-circular'] + } + }, + defaultVariants: { + variant: 'linear' + } +}); + +export interface MeterProps + extends MeterPrimitive.Root.Props, + VariantProps {} + +export interface MeterContextValue { + variant: 'linear' | 'circular'; + value: number; + percentage: number; +} + +export const MeterContext = createContext({ + variant: 'linear', + value: 0, + percentage: 0 +}); + +export const MeterRoot = forwardRef< + ElementRef, + MeterProps +>( + ( + { + className, + style = {}, + variant = 'linear', + children = , + value = 0, + min = 0, + max = 100, + ...props + }, + ref + ) => { + const percentage = ((value - min) * 100) / (max - min); + + return ( + + + {children} + + + ); + } +); + +MeterRoot.displayName = 'MeterRoot'; diff --git a/packages/raystack/components/meter/meter-track.tsx b/packages/raystack/components/meter/meter-track.tsx new file mode 100644 index 00000000..791d2f25 --- /dev/null +++ b/packages/raystack/components/meter/meter-track.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { Meter as MeterPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { type ElementRef, forwardRef, useContext } from 'react'; +import styles from './meter.module.css'; +import { MeterContext } from './meter-root'; + +export const MeterTrack = forwardRef< + ElementRef, + MeterPrimitive.Track.Props +>(({ className, children, ...props }, ref) => { + const { variant } = useContext(MeterContext); + + if (variant === 'circular') { + return ( + ( + + + {trackChildren} + + )} + > + } + /> + {children} + + ); + } + + return ( + + + {children} + + ); +}); + +MeterTrack.displayName = 'MeterTrack'; diff --git a/packages/raystack/components/meter/meter.module.css b/packages/raystack/components/meter/meter.module.css new file mode 100644 index 00000000..abd50123 --- /dev/null +++ b/packages/raystack/components/meter/meter.module.css @@ -0,0 +1,97 @@ +.meter { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); + width: 100%; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.label { + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); +} + +.value { + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); + text-align: right; +} + +.track { + position: relative; + width: 100%; + height: 4px; + overflow: clip; + border-radius: 1px; + background-color: var(--rs-color-background-neutral-secondary); +} + +.indicator { + height: 100%; + background-color: var(--rs-color-background-accent-emphasis); + transition: width 500ms; +} + +/* Circular variant — viewBox is 72×72, SVG scales to container size */ +.meter-variant-circular { + align-items: center; + justify-content: center; + position: relative; +} + +.circularSvg { + --rs-meter-track-size: 4px; + --rs-meter-radius: calc((72px - var(--rs-meter-track-size) * 2) / 2); + --rs-meter-circumference: calc(2 * 3.14159265 * var(--rs-meter-radius)); + width: 72px; + height: 72px; + aspect-ratio: 1; + transform: rotate(-90deg); +} + +.circularTrackCircle, +.circularIndicatorCircle { + cx: 50%; + cy: 50%; + r: var(--rs-meter-radius); + stroke-width: var(--rs-meter-track-size); + fill: none; +} + +.circularTrackCircle { + stroke: var(--rs-color-background-neutral-secondary); +} + +.circularIndicatorCircle { + stroke: var(--rs-color-background-accent-emphasis); + stroke-dasharray: var(--rs-meter-circumference); + stroke-dashoffset: calc( + var(--rs-meter-circumference) * + (1 - var(--rs-meter-percentage, 0) / 100) + ); + stroke-linecap: butt; + transition: stroke-dashoffset 500ms; +} + +.meter-variant-circular .value { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-weight: var(--rs-font-weight-medium); + text-align: center; + white-space: nowrap; +} diff --git a/packages/raystack/components/meter/meter.tsx b/packages/raystack/components/meter/meter.tsx new file mode 100644 index 00000000..51a5d595 --- /dev/null +++ b/packages/raystack/components/meter/meter.tsx @@ -0,0 +1,9 @@ +import { MeterLabel, MeterValue } from './meter-misc'; +import { MeterRoot } from './meter-root'; +import { MeterTrack } from './meter-track'; + +export const Meter = Object.assign(MeterRoot, { + Label: MeterLabel, + Value: MeterValue, + Track: MeterTrack +}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 95a9e531..d9fe0a56 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -43,6 +43,7 @@ export { Label } from './components/label'; export { Link } from './components/link'; export { List } from './components/list'; export { Menu } from './components/menu'; +export { Meter } from './components/meter'; export { Navbar } from './components/navbar'; export { Popover } from './components/popover'; export { PreviewCard } from './components/preview-card';