APG Patterns
English
English

Meter

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

デモ

ネイティブ HTML

ネイティブ HTML を優先

このカスタムコンポーネントを使用する前に、ネイティブの <meter> 要素の使用を検討してください。 ネイティブ要素は組み込みのセマンティクスを提供し、JavaScript なしで動作し、ARIA 属性を必要としません。

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

カスタム実装は、ネイティブ要素では提供できないカスタムスタイリングが必要な場合、または視覚的な外観をプログラムで制御する必要がある場合にのみ使用してください。

ユースケース ネイティブ HTML カスタム実装
基本的な値の表示 推奨 不要
JavaScript 無効時のサポート ネイティブで動作 フォールバックが必要
low/high/optimum しきい値 組み込みサポート 手動実装
カスタムスタイリング 制限あり(ブラウザ依存) 完全に制御可能
クロスブラウザでの一貫した外観 ブラウザによって異なる 一貫性あり
動的な値の更新 ネイティブで動作 完全に制御可能

ネイティブの <meter> 要素は、値のしきい値に基づいて自動的に色を変更するための lowhighoptimum 属性をサポートしています。この機能はカスタムコンポーネントでは手動実装が必要です。

アクセシビリティ

WAI-ARIA ロール

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

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

WAI-ARIA プロパティ

aria-valuenow

aria-valueminとaria-valuemaxの間である必要があります

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

aria-valuemin

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

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

aria-valuemax

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

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

aria-valuetext

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

文字列(例: "75% complete")
必須 いいえ
"75% complete", "3 out of 4 GB used"

aria-label

メーターに見えないラベルを提供します

文字列
必須 条件付き(表示されるラベルがない場合は必須)

aria-labelledby

外部要素をラベルとして参照します

ID参照
必須 条件付き(表示されるラベルが存在する場合は必須)

キーボードサポート

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

アクセシブルな名前

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

  • 表示されるラベル - 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準拠を検証します。メーターは表示専用の要素であるため、キーボードインタラクションのテストは該当しません。Meterコンポーネントは2層のテスト戦略を採用しています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のテストライブラリを使用してコンポーネントのレンダリング出力を検証します。これらのテストは正しいHTML構造とARIA属性を確認します。

  • ARIA属性(role="meter"、aria-valuenow、aria-valuemin、aria-valuemax)
  • 値のクランプ動作
  • アクセシブルな名前の処理
  • jest-axeによるアクセシビリティ検証

E2Eテスト(Playwright)

すべてのフレームワークで実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストは値の表示とフレームワーク間の一貫性をカバーします。

  • ライブブラウザでのARIA構造
  • 非インタラクティブな動作の検証
  • 値の表示の正確性
  • axe-coreによるアクセシビリティスキャン
  • フレームワーク間の一貫性チェック

テストカテゴリ

高優先度: ARIA 属性 (Unit + E2E)

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

高優先度: アクセシブルな名前 (Unit + E2E)

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

高優先度: 値のクランプ (Unit)

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

中優先度: アクセシビリティ (Unit + E2E)

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

中優先度: エッジケース (Unit + E2E)

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

低優先度: HTML 属性の継承 (Unit)

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

低優先度: フレームワーク間の一貫性 (E2E)

テスト 説明
Same meter count React、Vue、Svelte、Astroすべてが同じ数のメーターをレンダリング
Consistent ARIA attributes すべてのフレームワークで一貫したARIA構造

テストツール

完全なドキュメントについては testing-strategy.md (opens in new tab) を参照してください。

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');
    });
  });
});

リソース