APG Patterns
English GitHub
English GitHub

Meter

定義された範囲内で変化する数値のグラフィカル表示。

🤖 AI Implementation Guide

デモ

ネイティブ HTML

Use Native HTML First

Before using this custom component, consider using native <meter> elements. They provide built-in semantics, work without JavaScript, and require no ARIA attributes.

<label for="battery">Battery Level</label>
<meter id="battery" value="75" min="0" max="100">75%</meter>

Use custom implementations only when you need custom styling that native elements cannot provide, or when you need programmatic control over the visual appearance.

Use Case Native HTML Custom Implementation
Basic value display Recommended Not needed
JavaScript disabled support Works natively Requires fallback
low/high/optimum thresholds Built-in support Manual implementation
Custom styling Limited (browser-dependent) Full control
Consistent cross-browser appearance Varies by browser Consistent
Dynamic value updates Works natively Full control

The native <meter> element supports low, high, and optimum attributes for automatic color changes based on value thresholds. This functionality requires manual implementation in custom components.

アクセシビリティ

WAI-ARIA ロール

ロール 要素 説明
meter コンテナ要素 既知の範囲内のスカラー値を表示するメーターとして要素を識別します。

meter ロールは、定義された範囲内の数値のグラフィカル表示に使用されます。 インタラクティブではないため、デフォルトではフォーカスを受け取るべきではありません。

WAI-ARIA プロパティ

aria-valuenow

メーターの現在の数値を示します。すべてのメーター実装で必須です。

数値
必須 はい
範囲 aria-valueminaria-valuemax の間である必要があります

aria-valuemin

メーターの最小許容値を指定します。

数値
必須 はい
デフォルト 0

aria-valuemax

メーターの最大許容値を指定します。

数値
必須 はい
デフォルト 100

aria-valuetext

現在の値に対する人間が読みやすいテキストの代替を提供します。数値だけでは十分な意味を伝えられない場合に使用します。

文字列
必須 いいえ(値が文脈を必要とする場合は推奨)
"75% complete", "3 out of 4 GB used"

キーボードサポート

該当なし。 メーターパターンは表示専用の要素であり、インタラクティブではありません。 特定のユースケースのために明示的にフォーカス可能にされない限り、キーボードフォーカスを受け取るべきではありません。

アクセシブルな名前

メーターはアクセシブルな名前を持つ必要があります。これは以下の方法で提供できます:

  • 表示されるラベル - label プロパティを使用して表示されるラベルを提供
  • aria-label - メーターに見えないラベルを提供
  • aria-labelledby - 外部要素をラベルとして参照

ビジュアルデザイン

この実装は、アクセシブルなビジュアルデザインのためのWCAGガイドラインに従っています:

  • 視覚的な塗りつぶしバー - 現在の値を比例的に表現
  • 数値表示 - 現在の値を示すオプションのテキスト
  • 表示されるラベル - メーターの目的を識別するオプションのラベル
  • 強制カラーモード - Windowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用

参考資料

ソースコード

Meter.tsx
import { clsx } from 'clsx';

// Label: one of these required (exclusive)
type LabelProps =
  | { label: string; 'aria-label'?: never; 'aria-labelledby'?: never }
  | { label?: never; 'aria-label': string; 'aria-labelledby'?: never }
  | { label?: never; 'aria-label'?: never; 'aria-labelledby': string };

// ValueText: exclusive with format
type ValueTextProps =
  | { valueText: string; format?: never }
  | { valueText?: never; format?: string }
  | { valueText?: never; format?: never };

type MeterBaseProps = {
  value: number;
  min?: number;
  max?: number;
  clamp?: boolean;
  showValue?: boolean;
  id?: string;
  className?: string;
  tabIndex?: number;
  'aria-describedby'?: string;
  'data-testid'?: string;
};

export type MeterProps = MeterBaseProps & LabelProps & ValueTextProps;

const clampNumber = (value: number, min: number, max: number, shouldClamp: boolean): number => {
  if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max)) {
    return value;
  }
  return shouldClamp ? Math.min(max, Math.max(min, value)) : value;
};

const calculatePercentage = (value: number, min: number, max: number): number => {
  if (max === min) return 0;
  return ((value - min) / (max - min)) * 100;
};

// Format value helper
const formatValueText = (
  value: number,
  formatStr: string | undefined,
  min: number,
  max: number
): string => {
  if (!formatStr) return String(value);
  return formatStr
    .replace('{value}', String(value))
    .replace('{min}', String(min))
    .replace('{max}', String(max));
};

export const Meter: React.FC<MeterProps> = ({
  value,
  min = 0,
  max = 100,
  clamp = true,
  showValue = true,
  label,
  valueText,
  format,
  className,
  ...rest
}) => {
  const normalizedValue = clampNumber(value, min, max, clamp);
  const percentage = calculatePercentage(normalizedValue, min, max);

  // Determine aria-valuetext
  const ariaValueText =
    valueText ?? (format ? formatValueText(normalizedValue, format, min, max) : undefined);

  // Determine display text (valueText takes priority, then format, then raw value)
  const displayText = valueText ? valueText : formatValueText(normalizedValue, format, min, max);

  return (
    <div
      role="meter"
      aria-valuenow={normalizedValue}
      aria-valuemin={min}
      aria-valuemax={max}
      aria-valuetext={ariaValueText}
      aria-label={label ?? rest['aria-label']}
      aria-labelledby={rest['aria-labelledby']}
      className={clsx('apg-meter', className)}
      id={rest.id}
      tabIndex={rest.tabIndex}
      aria-describedby={rest['aria-describedby']}
      data-testid={rest['data-testid']}
    >
      {label && (
        <span className="apg-meter-label" aria-hidden="true">
          {label}
        </span>
      )}
      <div className="apg-meter-track" aria-hidden="true">
        <div
          className="apg-meter-fill"
          style={{ width: `${Math.max(0, Math.min(100, percentage))}%` }}
        />
      </div>
      {showValue && (
        <span className="apg-meter-value" aria-hidden="true">
          {displayText}
        </span>
      )}
    </div>
  );
};

使い方

Example
import { Meter } from './Meter';

function App() {
  return (
    <div>
      {/* Basic usage with aria-label */}
      <Meter value={75} aria-label="CPU Usage" />

      {/* With visible label */}
      <Meter value={75} label="CPU Usage" />

      {/* With valueText for human-readable value */}
      <Meter
        value={75}
        label="Progress"
        valueText="75%"
      />

      {/* Custom range with valueText */}
      <Meter
        value={3.5}
        min={0}
        max={5}
        label="Rating"
        valueText="3.5 out of 5"
      />

      {/* With format pattern */}
      <Meter
        value={75}
        label="Download Progress"
        format="{value}%"
      />
    </div>
  );
}

API

プロパティ デフォルト 説明
value number required メーターの現在値
min number 0 最小値
max number 100 最大値
clamp boolean true 値を min/max の範囲内に制限するかどうか
showValue boolean true 値のテキストを表示するかどうか
label string - 表示ラベル(aria-label としても使用される)
valueText string - aria-valuetext 用の人間が読める値
format string - 表示と aria-valuetext のフォーマットパターン(例: "{value}%", "{value} of {max}")

アクセシビリティのため、labelaria-label、または aria-labelledby のいずれかが必須です。

テスト

テストは、ARIA属性、値処理、アクセシビリティ要件のAPG準拠を検証します。 メーターは表示専用の要素であるため、キーボードインタラクションのテストは該当しません。

テストカテゴリ

高優先度: ARIA 属性

テスト 説明
role="meter" 要素が meter ロールを持つ
aria-valuenow 現在の値が正しく設定されている
aria-valuemin 最小値が設定されている(デフォルト: 0)
aria-valuemax 最大値が設定されている(デフォルト: 100)
aria-valuetext 提供された場合、人間が読めるテキストが設定されている

高優先度: アクセシブルな名前

テスト 説明
aria-label aria-label 属性によるアクセシブルな名前
aria-labelledby 外部要素参照によるアクセシブルな名前
visible label 表示されるラベルがアクセシブルな名前を提供する

高優先度: 値のクランプ

テスト 説明
clamp above max 最大値を超える値が最大値にクランプされる
clamp below min 最小値を下回る値が最小値にクランプされる
no clamp clamp=false でクランプを無効化できる

中優先度: アクセシビリティ

テスト 説明
axe violations axe-core によってアクセシビリティ違反が検出されない
focus behavior デフォルトではフォーカス不可

中優先度: エッジケース

テスト 説明
decimal values 小数値を正しく処理する
negative range 負の最小/最大範囲を処理する
large values 大きな数値を処理する

低優先度: HTML 属性の継承

テスト 説明
className カスタムクラスがコンテナに適用される
id ID 属性が正しく設定される
data-* データ属性が渡される

テストツール

Meter.test.tsx
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import { Meter } from './Meter';

describe('Meter', () => {
  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="meter"', () => {
      render(<Meter value={50} aria-label="Progress" />);
      expect(screen.getByRole('meter')).toBeInTheDocument();
    });

    it('has aria-valuenow set to current value', () => {
      render(<Meter value={75} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuenow', '75');
    });

    it('has aria-valuemin set (default: 0)', () => {
      render(<Meter value={50} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuemin', '0');
    });

    it('has aria-valuemax set (default: 100)', () => {
      render(<Meter value={50} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuemax', '100');
    });

    it('has custom aria-valuemin when provided', () => {
      render(<Meter value={50} min={10} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuemin', '10');
    });

    it('has custom aria-valuemax when provided', () => {
      render(<Meter value={50} max={200} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuemax', '200');
    });

    it('has aria-valuetext when valueText provided', () => {
      render(<Meter value={75} valueText="75 percent complete" aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuetext', '75 percent complete');
    });

    it('does not have aria-valuetext when not provided', () => {
      render(<Meter value={75} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).not.toHaveAttribute('aria-valuetext');
    });

    it('uses format for aria-valuetext', () => {
      render(<Meter value={75} min={0} max={100} format="{value}%" aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuetext', '75%');
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      render(<Meter value={50} aria-label="CPU Usage" />);
      expect(screen.getByRole('meter', { name: 'CPU Usage' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(
        <>
          <span id="meter-label">Battery Level</span>
          <Meter value={80} aria-labelledby="meter-label" />
        </>
      );
      expect(screen.getByRole('meter', { name: 'Battery Level' })).toBeInTheDocument();
    });

    it('has accessible name via visible label', () => {
      render(<Meter value={50} label="Storage Used" />);
      expect(screen.getByRole('meter', { name: 'Storage Used' })).toBeInTheDocument();
    });
  });

  // 🔴 High Priority: Value Clamping
  describe('Value Clamping', () => {
    it('clamps value above max to max', () => {
      render(<Meter value={150} min={0} max={100} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuenow', '100');
    });

    it('clamps value below min to min', () => {
      render(<Meter value={-50} min={0} max={100} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuenow', '0');
    });

    it('does not clamp when clamp=false', () => {
      render(<Meter value={150} min={0} max={100} clamp={false} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuenow', '150');
    });
  });

  // 🔴 High Priority: Focus Behavior
  describe('Focus Behavior', () => {
    it('is not focusable by default', () => {
      render(<Meter value={50} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).not.toHaveAttribute('tabindex');
    });

    it('is focusable when tabIndex is provided', () => {
      render(<Meter value={50} aria-label="Progress" tabIndex={0} />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('tabindex', '0');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(<Meter value={50} aria-label="Progress" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with visible label', async () => {
      const { container } = render(<Meter value={50} label="CPU Usage" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(
        <>
          <span id="label">Battery</span>
          <Meter value={80} aria-labelledby="label" />
        </>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with valueText', async () => {
      const { container } = render(
        <Meter value={75} valueText="75% complete" aria-label="Progress" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations at boundary values', async () => {
      const { container } = render(<Meter value={0} aria-label="Empty" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal values correctly', () => {
      render(<Meter value={33.33} aria-label="Progress" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuenow', '33.33');
    });

    it('handles negative min/max range', () => {
      render(<Meter value={0} min={-50} max={50} aria-label="Temperature" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuenow', '0');
      expect(meter).toHaveAttribute('aria-valuemin', '-50');
      expect(meter).toHaveAttribute('aria-valuemax', '50');
    });

    it('handles large values', () => {
      render(<Meter value={500000} min={0} max={1000000} aria-label="Revenue" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuenow', '500000');
      expect(meter).toHaveAttribute('aria-valuemax', '1000000');
    });

    it('handles zero range edge case (min equals max)', () => {
      render(<Meter value={50} min={50} max={50} aria-label="Static" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-valuenow', '50');
    });
  });

  // 🟡 Medium Priority: Visual Display
  describe('Visual Display', () => {
    it('shows value when showValue is true (default)', () => {
      render(<Meter value={75} aria-label="Progress" />);
      expect(screen.getByText('75')).toBeInTheDocument();
    });

    it('hides value when showValue is false', () => {
      render(<Meter value={75} aria-label="Progress" showValue={false} />);
      expect(screen.queryByText('75')).not.toBeInTheDocument();
    });

    it('displays formatted value when format provided', () => {
      render(<Meter value={75} format="{value}%" aria-label="Progress" />);
      expect(screen.getByText('75%')).toBeInTheDocument();
    });

    it('displays visible label when label provided', () => {
      render(<Meter value={50} label="CPU Usage" />);
      expect(screen.getByText('CPU Usage')).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies className to container', () => {
      render(<Meter value={50} aria-label="Progress" className="custom-meter" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveClass('custom-meter');
    });

    it('sets id attribute', () => {
      render(<Meter value={50} aria-label="Progress" id="my-meter" />);
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('id', 'my-meter');
    });

    it('passes through data-* attributes', () => {
      render(<Meter value={50} aria-label="Progress" data-testid="custom-meter" />);
      expect(screen.getByTestId('custom-meter')).toBeInTheDocument();
    });

    it('supports aria-describedby', () => {
      render(
        <>
          <Meter value={50} aria-label="Progress" aria-describedby="desc" />
          <p id="desc">This shows your progress</p>
        </>
      );
      const meter = screen.getByRole('meter');
      expect(meter).toHaveAttribute('aria-describedby', 'desc');
    });
  });
});

リソース