-
Notifications
You must be signed in to change notification settings - Fork 13
feat: add Meter component #691
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 } | ||
| }, | ||
| 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>` | ||
| }; | ||
| 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 |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep the generated API tables aligned with the documented surface. The docs page demonstrates 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 |
||
|
|
||
| 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; | ||
| } | ||
| 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'); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { Meter } from './meter'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Constrain the playground controls to a valid range.
These controls can currently produce
min >= maxand values outside[min, max].packages/raystack/components/meter/meter-root.tsx:54-68divides by(max - min)directly, so the docs demo can end up renderingInfinity/NaNor percentages above 100. Please keepmax > minand clampvaluewhen the controls change so the playground can't enter a broken state.🤖 Prompt for AI Agents