Skip to content
Open
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
140 changes: 140 additions & 0 deletions apps/www/src/content/docs/components/meter/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
'use client';

import { getPropsString } from '@/lib/utils';

export const getCode = (props: any) => {
return `<Meter${getPropsString(props)} />`;
};

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 }
},
Comment on lines +11 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Constrain the playground controls to a valid range.

These controls can currently produce min >= max and values outside [min, max]. packages/raystack/components/meter/meter-root.tsx:54-68 divides by (max - min) directly, so the docs demo can end up rendering Infinity/NaN or percentages above 100. Please keep max > min and clamp value when the controls change so the playground can't enter a broken state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/www/src/content/docs/components/meter/demo.ts` around lines 11 - 20, The
playground controls can produce min >= max and out-of-range values; update the
controls object in demo.ts so the 'max' control always stays greater than 'min'
and 'value' is clamped to [min, max] whenever any control changes: enforce a
relation when updating 'min' and 'max' (e.g., if newMin >= currentMax, set max =
newMin + 1; if newMax <= currentMin, set min = newMax - 1), and ensure the
'value' control is adjusted/clamped to Math.min(max, Math.max(min, value)) after
min/max changes; also update the 'value' control's metadata to reflect the
current min/max bounds so the playground UI cannot set values outside the valid
range (referencing the controls object and the 'min', 'max', and 'value' fields
and the meter-root calculation that divides by (max - min)).

getCode
};

export const directUsageDemo = {
type: 'code',
code: `<Flex direction="column" gap="large" style={{ width: "300px" }}>
<Meter value={40} />
<Meter value={70} />
<Meter value={100} />
</Flex>`
};

export const variantDemo = {
type: 'code',
tabs: [
{
name: 'Linear',
code: `<Flex direction="column" gap="large" style={{ width: "300px" }}>
<Meter value={15}>
<Flex justify="between">
<Meter.Label>Storage used</Meter.Label>
<Meter.Value />
</Flex>
<Meter.Track />
</Meter>
</Flex>`
},
{
name: 'Circular',
code: `<Flex gap="large" align="center">
<Meter variant="circular" value={70}>
<Meter.Track />
<Meter.Value />
</Meter>
<Meter variant="circular" value={30}>
<Meter.Track />
<Meter.Value />
</Meter>
<Meter variant="circular" value={90}>
<Meter.Track />
<Meter.Value />
</Meter>
</Flex>`
}
]
};

export const customizationDemo = {
type: 'code',
tabs: [
{
name: 'Linear',
code: `<Flex direction="column" gap="large" style={{ width: "300px" }}>
<Meter value={60}>
<Meter.Track style={{ height: 2 }} />
</Meter>
<Meter value={60}>
<Meter.Track />
</Meter>
<Meter value={60}>
<Meter.Track style={{ height: 8 }} />
</Meter>
</Flex>`
},
{
name: 'Circular',
code: `<Flex gap="large" align="center">
<Meter variant="circular" value={60}>
<Meter.Track style={{ width: 48, height: 48 }} />
<Meter.Value />
</Meter>
<Meter variant="circular" value={60}>
<Meter.Track />
<Meter.Value />
</Meter>
<Meter variant="circular" value={60}>
<Meter.Track style={{ width: 96, height: 96, "--rs-meter-track-size": "2px" }} />
<Meter.Value />
</Meter>
<Meter variant="circular" value={60}>
<Meter.Track style={{ "--rs-meter-track-size": "8px" }} />
<Meter.Value />
</Meter>
</Flex>`
}
]
};

export const withLabelsDemo = {
type: 'code',
code: `<Flex direction="column" gap="large" style={{ width: "300px" }}>
<Meter value={60}>
<Flex justify="between">
<Meter.Label>CPU Usage</Meter.Label>
<Meter.Value />
</Flex>
<Meter.Track />
</Meter>
<Meter value={85}>
<Flex justify="between">
<Meter.Label>Memory</Meter.Label>
<Meter.Value />
</Flex>
<Meter.Track />
</Meter>
</Flex>`
};

export const customRangeDemo = {
type: 'code',
code: `<Flex direction="column" gap="large" style={{ width: "300px" }}>
<Meter value={750} min={0} max={1000}>
<Flex justify="between">
<Meter.Label>API Calls</Meter.Label>
<Meter.Value />
</Flex>
<Meter.Track />
</Meter>
</Flex>`
};
91 changes: 91 additions & 0 deletions apps/www/src/content/docs/components/meter/index.mdx
Original file line number Diff line number Diff line change
@@ -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";

<Demo data={playground} />

## Anatomy

Import and assemble the component:

```tsx
import { Meter } from '@raystack/apsara'

{/* Direct usage — renders track automatically */}
<Meter value={40} />

{/* Composable usage */}
<Meter value={40}>
<Meter.Label>Storage used</Meter.Label>
<Meter.Value />
<Meter.Track />
</Meter>
```

## API Reference

### Root

The main container for the meter. Renders a default track when no children are provided.

<auto-type-table path="./props.ts" name="MeterProps" />

### Label

Displays a label for the meter.

<auto-type-table path="./props.ts" name="MeterLabelProps" />

### Value

Displays the formatted current value as a percentage.

<auto-type-table path="./props.ts" name="MeterValueProps" />

### Track

Contains the indicator that visualizes the current value.

<auto-type-table path="./props.ts" name="MeterTrackProps" />

## Examples

### Variant

The meter supports `linear` and `circular` variants.

<Demo data={variantDemo} />

### Direct Usage

The simplest way to use the meter. When no children are provided, it renders the track automatically.

<Demo data={directUsageDemo} />

### 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.

<Demo data={customizationDemo} />

### With Labels

Compose with `Meter.Label` and `Meter.Value` for additional context.

<Demo data={withLabelsDemo} />

### Custom Range

Use `min` and `max` to define custom value ranges.

<Demo data={customRangeDemo} />

## Accessibility

- Uses the `meter` ARIA role
- Sets `aria-valuenow`, `aria-valuemin`, and `aria-valuemax` attributes
- Supports `aria-label` and `aria-valuetext` for screen readers
43 changes: 43 additions & 0 deletions apps/www/src/content/docs/components/meter/props.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +1 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep the generated API tables aligned with the documented surface.

The docs page demonstrates Meter.Track style={...} customization and explicitly calls out aria-label / aria-valuetext, but those props never appear in the interfaces that drive the API reference. Right now the generated tables under-document the main customization and accessibility hooks for this component.

Suggested fix
 export interface MeterProps {
   /** The current value of the meter. */
   value: number;
@@
   /** Additional CSS class name. */
   className?: string;
+
+  /** Accessible name for screen readers. */
+  'aria-label'?: string;
+
+  /** Screen-reader-specific text for the current value. */
+  'aria-valuetext'?: string;
 }
@@
 export interface MeterTrackProps {
   /** Additional CSS class name. */
   className?: string;
+
+  /** Inline styles for sizing and CSS custom properties like `--rs-meter-track-size`. */
+  style?: React.CSSProperties;
 }

Also applies to: 40-43

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/www/src/content/docs/components/meter/props.ts` around lines 1 - 25, The
MeterProps interface is missing documented customization and accessibility hooks
referenced in the docs; update the MeterProps interface (and any related
exported prop types) to include a trackStyle prop for styling Meter.Track (e.g.,
trackStyle?: React.CSSProperties) and add accessibility props ariaLabel?: string
and ariaValueText?: string (or similarly named camelCase variants used across
the codebase) so the API tables match the documented usage of Meter.Track
style={...} and the aria-label / aria-valuetext props. Ensure the new props are
documented with the same JSDoc defaults/comments as the rest of the interface.


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;
}
119 changes: 119 additions & 0 deletions packages/raystack/components/meter/__tests__/meter.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Meter value={50} />);
const meter = container.querySelector(`.${styles.meter}`);
expect(meter).toBeInTheDocument();
});

it('forwards ref correctly', () => {
const ref = vi.fn();
render(<Meter ref={ref} value={50} />);
expect(ref).toHaveBeenCalled();
});

it('applies custom className', () => {
const { container } = render(
<Meter className='custom-meter' value={50} />
);
const meter = container.querySelector(`.${styles.meter}`);
expect(meter).toHaveClass('custom-meter');
});

it('renders track and indicator by default', () => {
const { container } = render(<Meter value={50} />);
expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument();
expect(
container.querySelector(`.${styles.indicator}`)
).toBeInTheDocument();
});

it('renders default track when no children provided', () => {
const { container } = render(<Meter value={40} />);
expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument();
});
});

describe('Variants', () => {
it('defaults to linear variant', () => {
const { container } = render(<Meter value={50} />);
const meter = container.querySelector(`.${styles.meter}`);
expect(meter).not.toHaveClass(styles['meter-variant-circular']);
});

it('renders circular variant', () => {
const { container } = render(<Meter variant='circular' value={70} />);
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(<Meter variant='circular' value={70} />);
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(
<Meter value={50}>
<Meter.Label>Storage</Meter.Label>
<Meter.Track />
</Meter>
);
expect(screen.getByText('Storage')).toBeInTheDocument();
});

it('renders Value sub-component', () => {
render(
<Meter value={50}>
<Meter.Value />
<Meter.Track />
</Meter>
);
expect(screen.getByText('50%')).toBeInTheDocument();
});

it('renders custom children instead of default track', () => {
const { container } = render(
<Meter value={50}>
<Meter.Label>Custom</Meter.Label>
<Meter.Track />
</Meter>
);
expect(screen.getByText('Custom')).toBeInTheDocument();
expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument();
});
});

describe('Accessibility', () => {
it('has meter role', () => {
render(<Meter value={50} aria-label='Storage' />);
expect(screen.getByRole('meter')).toBeInTheDocument();
});

it('sets aria-valuenow', () => {
render(<Meter value={75} aria-label='Storage' />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuenow', '75');
});

it('sets aria-valuemin and aria-valuemax', () => {
render(<Meter value={50} min={0} max={200} aria-label='Storage' />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuemin', '0');
expect(meter).toHaveAttribute('aria-valuemax', '200');
});
});
});
1 change: 1 addition & 0 deletions packages/raystack/components/meter/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Meter } from './meter';
Loading