Skip to content

Commit e370080

Browse files
feat: add Meter component (#691)
feat: meter
1 parent 23686c9 commit e370080

11 files changed

Lines changed: 662 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
'use client';
2+
3+
import { getPropsString } from '@/lib/utils';
4+
5+
export const getCode = (props: any) => {
6+
return `<Meter${getPropsString(props)} />`;
7+
};
8+
9+
export const playground = {
10+
type: 'playground',
11+
controls: {
12+
value: { type: 'number', initialValue: 40, min: 0, max: 100 },
13+
variant: {
14+
type: 'select',
15+
initialValue: 'linear',
16+
options: ['linear', 'circular']
17+
},
18+
min: { type: 'number', defaultValue: 0, min: 0, max: 99 },
19+
max: { type: 'number', defaultValue: 100, min: 1, max: 100 }
20+
},
21+
getCode
22+
};
23+
24+
export const directUsageDemo = {
25+
type: 'code',
26+
code: `<Flex direction="column" gap="large" style={{ width: "300px" }}>
27+
<Meter value={40} />
28+
<Meter value={70} />
29+
<Meter value={100} />
30+
</Flex>`
31+
};
32+
33+
export const variantDemo = {
34+
type: 'code',
35+
tabs: [
36+
{
37+
name: 'Linear',
38+
code: `<Flex direction="column" gap="large" style={{ width: "300px" }}>
39+
<Meter value={15}>
40+
<Flex justify="between">
41+
<Meter.Label>Storage used</Meter.Label>
42+
<Meter.Value />
43+
</Flex>
44+
<Meter.Track />
45+
</Meter>
46+
</Flex>`
47+
},
48+
{
49+
name: 'Circular',
50+
code: `<Flex gap="large" align="center">
51+
<Meter variant="circular" value={70}>
52+
<Meter.Track />
53+
<Meter.Value />
54+
</Meter>
55+
<Meter variant="circular" value={30}>
56+
<Meter.Track />
57+
<Meter.Value />
58+
</Meter>
59+
<Meter variant="circular" value={90}>
60+
<Meter.Track />
61+
<Meter.Value />
62+
</Meter>
63+
</Flex>`
64+
}
65+
]
66+
};
67+
68+
export const customizationDemo = {
69+
type: 'code',
70+
tabs: [
71+
{
72+
name: 'Linear',
73+
code: `<Flex direction="column" gap="large" style={{ width: "300px" }}>
74+
<Meter value={60}>
75+
<Meter.Track style={{ height: 2 }} />
76+
</Meter>
77+
<Meter value={60}>
78+
<Meter.Track />
79+
</Meter>
80+
<Meter value={60}>
81+
<Meter.Track style={{ height: 8 }} />
82+
</Meter>
83+
</Flex>`
84+
},
85+
{
86+
name: 'Circular',
87+
code: `<Flex gap="large" align="center">
88+
<Meter variant="circular" value={60}>
89+
<Meter.Track style={{ width: 48, height: 48 }} />
90+
<Meter.Value />
91+
</Meter>
92+
<Meter variant="circular" value={60}>
93+
<Meter.Track />
94+
<Meter.Value />
95+
</Meter>
96+
<Meter variant="circular" value={60}>
97+
<Meter.Track style={{ width: 96, height: 96, "--rs-meter-track-size": "2px" }} />
98+
<Meter.Value />
99+
</Meter>
100+
<Meter variant="circular" value={60}>
101+
<Meter.Track style={{ "--rs-meter-track-size": "8px" }} />
102+
<Meter.Value />
103+
</Meter>
104+
</Flex>`
105+
}
106+
]
107+
};
108+
109+
export const withLabelsDemo = {
110+
type: 'code',
111+
code: `<Flex direction="column" gap="large" style={{ width: "300px" }}>
112+
<Meter value={60}>
113+
<Flex justify="between">
114+
<Meter.Label>CPU Usage</Meter.Label>
115+
<Meter.Value />
116+
</Flex>
117+
<Meter.Track />
118+
</Meter>
119+
<Meter value={85}>
120+
<Flex justify="between">
121+
<Meter.Label>Memory</Meter.Label>
122+
<Meter.Value />
123+
</Flex>
124+
<Meter.Track />
125+
</Meter>
126+
</Flex>`
127+
};
128+
129+
export const customRangeDemo = {
130+
type: 'code',
131+
code: `<Flex direction="column" gap="large" style={{ width: "300px" }}>
132+
<Meter value={750} min={0} max={1000}>
133+
<Flex justify="between">
134+
<Meter.Label>API Calls</Meter.Label>
135+
<Meter.Value />
136+
</Flex>
137+
<Meter.Track />
138+
</Meter>
139+
</Flex>`
140+
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
title: Meter
3+
description: A graphical display of a numeric value within a known range.
4+
source: packages/raystack/components/meter
5+
---
6+
7+
import { playground, directUsageDemo, variantDemo, customizationDemo, withLabelsDemo, customRangeDemo } from "./demo.ts";
8+
9+
<Demo data={playground} />
10+
11+
## Anatomy
12+
13+
Import and assemble the component:
14+
15+
```tsx
16+
import { Meter } from '@raystack/apsara'
17+
18+
{/* Direct usage — renders track automatically */}
19+
<Meter value={40} />
20+
21+
{/* Composable usage */}
22+
<Meter value={40}>
23+
<Meter.Label>Storage used</Meter.Label>
24+
<Meter.Value />
25+
<Meter.Track />
26+
</Meter>
27+
```
28+
29+
## API Reference
30+
31+
### Root
32+
33+
The main container for the meter. Renders a default track when no children are provided.
34+
35+
<auto-type-table path="./props.ts" name="MeterProps" />
36+
37+
### Label
38+
39+
Displays a label for the meter.
40+
41+
<auto-type-table path="./props.ts" name="MeterLabelProps" />
42+
43+
### Value
44+
45+
Displays the formatted current value as a percentage.
46+
47+
<auto-type-table path="./props.ts" name="MeterValueProps" />
48+
49+
### Track
50+
51+
Contains the indicator that visualizes the current value.
52+
53+
<auto-type-table path="./props.ts" name="MeterTrackProps" />
54+
55+
## Examples
56+
57+
### Variant
58+
59+
The meter supports `linear` and `circular` variants.
60+
61+
<Demo data={variantDemo} />
62+
63+
### Direct Usage
64+
65+
The simplest way to use the meter. When no children are provided, it renders the track automatically.
66+
67+
<Demo data={directUsageDemo} />
68+
69+
### Customization
70+
71+
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.
72+
73+
<Demo data={customizationDemo} />
74+
75+
### With Labels
76+
77+
Compose with `Meter.Label` and `Meter.Value` for additional context.
78+
79+
<Demo data={withLabelsDemo} />
80+
81+
### Custom Range
82+
83+
Use `min` and `max` to define custom value ranges.
84+
85+
<Demo data={customRangeDemo} />
86+
87+
## Accessibility
88+
89+
- Uses the `meter` ARIA role
90+
- Sets `aria-valuenow`, `aria-valuemin`, and `aria-valuemax` attributes
91+
- Supports `aria-label` and `aria-valuetext` for screen readers
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
export interface MeterProps {
2+
/** The current value of the meter. */
3+
value: number;
4+
5+
/**
6+
* Minimum value.
7+
* @default 0
8+
*/
9+
min?: number;
10+
11+
/**
12+
* Maximum value.
13+
* @default 100
14+
*/
15+
max?: number;
16+
17+
/**
18+
* The visual style of the meter.
19+
* @default "linear"
20+
*/
21+
variant?: 'linear' | 'circular';
22+
23+
/** Additional CSS class name. */
24+
className?: string;
25+
}
26+
27+
export interface MeterLabelProps {
28+
/** The label text content. */
29+
children: React.ReactNode;
30+
31+
/** Additional CSS class name. */
32+
className?: string;
33+
}
34+
35+
export interface MeterValueProps {
36+
/** Additional CSS class name. */
37+
className?: string;
38+
}
39+
40+
export interface MeterTrackProps {
41+
/** Additional CSS class name. */
42+
className?: string;
43+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { Meter } from '../meter';
4+
import styles from '../meter.module.css';
5+
6+
describe('Meter', () => {
7+
describe('Basic Rendering', () => {
8+
it('renders meter element', () => {
9+
const { container } = render(<Meter value={50} />);
10+
const meter = container.querySelector(`.${styles.meter}`);
11+
expect(meter).toBeInTheDocument();
12+
});
13+
14+
it('forwards ref correctly', () => {
15+
const ref = vi.fn();
16+
render(<Meter ref={ref} value={50} />);
17+
expect(ref).toHaveBeenCalled();
18+
});
19+
20+
it('applies custom className', () => {
21+
const { container } = render(
22+
<Meter className='custom-meter' value={50} />
23+
);
24+
const meter = container.querySelector(`.${styles.meter}`);
25+
expect(meter).toHaveClass('custom-meter');
26+
});
27+
28+
it('renders track and indicator by default', () => {
29+
const { container } = render(<Meter value={50} />);
30+
expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument();
31+
expect(
32+
container.querySelector(`.${styles.indicator}`)
33+
).toBeInTheDocument();
34+
});
35+
36+
it('renders default track when no children provided', () => {
37+
const { container } = render(<Meter value={40} />);
38+
expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument();
39+
});
40+
});
41+
42+
describe('Variants', () => {
43+
it('defaults to linear variant', () => {
44+
const { container } = render(<Meter value={50} />);
45+
const meter = container.querySelector(`.${styles.meter}`);
46+
expect(meter).not.toHaveClass(styles['meter-variant-circular']);
47+
});
48+
49+
it('renders circular variant', () => {
50+
const { container } = render(<Meter variant='circular' value={70} />);
51+
const meter = container.querySelector(`.${styles.meter}`);
52+
expect(meter).toHaveClass(styles['meter-variant-circular']);
53+
});
54+
55+
it('renders SVG track and indicator for circular variant', () => {
56+
const { container } = render(<Meter variant='circular' value={70} />);
57+
expect(container.querySelector('svg')).toBeInTheDocument();
58+
expect(
59+
container.querySelector(`.${styles.circularTrackCircle}`)
60+
).toBeInTheDocument();
61+
expect(
62+
container.querySelector(`.${styles.circularIndicatorCircle}`)
63+
).toBeInTheDocument();
64+
});
65+
});
66+
67+
describe('Sub-components', () => {
68+
it('renders Label sub-component', () => {
69+
render(
70+
<Meter value={50}>
71+
<Meter.Label>Storage</Meter.Label>
72+
<Meter.Track />
73+
</Meter>
74+
);
75+
expect(screen.getByText('Storage')).toBeInTheDocument();
76+
});
77+
78+
it('renders Value sub-component', () => {
79+
render(
80+
<Meter value={50}>
81+
<Meter.Value />
82+
<Meter.Track />
83+
</Meter>
84+
);
85+
expect(screen.getByText('50%')).toBeInTheDocument();
86+
});
87+
88+
it('renders custom children instead of default track', () => {
89+
const { container } = render(
90+
<Meter value={50}>
91+
<Meter.Label>Custom</Meter.Label>
92+
<Meter.Track />
93+
</Meter>
94+
);
95+
expect(screen.getByText('Custom')).toBeInTheDocument();
96+
expect(container.querySelector(`.${styles.track}`)).toBeInTheDocument();
97+
});
98+
});
99+
100+
describe('Accessibility', () => {
101+
it('has meter role', () => {
102+
render(<Meter value={50} aria-label='Storage' />);
103+
expect(screen.getByRole('meter')).toBeInTheDocument();
104+
});
105+
106+
it('sets aria-valuenow', () => {
107+
render(<Meter value={75} aria-label='Storage' />);
108+
const meter = screen.getByRole('meter');
109+
expect(meter).toHaveAttribute('aria-valuenow', '75');
110+
});
111+
112+
it('sets aria-valuemin and aria-valuemax', () => {
113+
render(<Meter value={50} min={0} max={200} aria-label='Storage' />);
114+
const meter = screen.getByRole('meter');
115+
expect(meter).toHaveAttribute('aria-valuemin', '0');
116+
expect(meter).toHaveAttribute('aria-valuemax', '200');
117+
});
118+
});
119+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Meter } from './meter';

0 commit comments

Comments
 (0)