APG Patterns
English
English

Meter

定義された範囲内の数値をグラフィカルに表示します。

デモ

Native 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.svelte
<script lang="ts">
  interface MeterProps {
    value: number;
    min?: number;
    max?: number;
    clamp?: boolean;
    showValue?: boolean;
    label?: string;
    valueText?: string;
    /** Format pattern for dynamic value display (e.g., "{value}%", "{value} of {max}") */
    format?: string;
    [key: string]: unknown;
  }

  let {
    value,
    min = 0,
    max = 100,
    clamp = true,
    showValue = true,
    label,
    valueText,
    format,
    ...restProps
  }: MeterProps = $props();

  function clampNumber(val: number, minVal: number, maxVal: number, shouldClamp: boolean): number {
    if (!Number.isFinite(val) || !Number.isFinite(minVal) || !Number.isFinite(maxVal)) {
      return val;
    }
    return shouldClamp ? Math.min(maxVal, Math.max(minVal, val)) : val;
  }

  // Format value helper
  function formatValueText(
    val: number,
    formatStr: string | undefined,
    minVal: number,
    maxVal: number
  ): string {
    if (!formatStr) return String(val);
    return formatStr
      .replace('{value}', String(val))
      .replace('{min}', String(minVal))
      .replace('{max}', String(maxVal));
  }

  const normalizedValue = $derived(clampNumber(value, min, max, clamp));

  const percentage = $derived(() => {
    if (max === min) return 0;
    const pct = ((normalizedValue - min) / (max - min)) * 100;
    return Math.max(0, Math.min(100, pct));
  });

  const ariaValueText = $derived(
    valueText ?? (format ? formatValueText(normalizedValue, format, min, max) : undefined)
  );

  const displayText = $derived(
    valueText ? valueText : formatValueText(normalizedValue, format, min, max)
  );
</script>

<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
  role="meter"
  aria-valuenow={normalizedValue}
  aria-valuemin={min}
  aria-valuemax={max}
  aria-valuetext={ariaValueText}
  aria-label={label || restProps['aria-label']}
  aria-labelledby={restProps['aria-labelledby']}
  aria-describedby={restProps['aria-describedby']}
  class="apg-meter {restProps.class || ''}"
  id={restProps.id}
  tabindex={restProps.tabindex}
  data-testid={restProps['data-testid']}
>
  {#if label}
    <span class="apg-meter-label" aria-hidden="true">
      {label}
    </span>
  {/if}
  <div class="apg-meter-track" aria-hidden="true">
    <div class="apg-meter-fill" style="width: {percentage()}%"></div>
  </div>
  {#if showValue}
    <span class="apg-meter-value" aria-hidden="true">
      {displayText}
    </span>
  {/if}
</div>

使い方

使用例
<script>
  import Meter from './Meter.svelte';
</script>

<!-- aria-label を使用した基本的な使用法 -->
<Meter value={75} aria-label="CPU Usage" />

<!-- 表示ラベルあり -->
<Meter value={75} label="CPU Usage" />

<!-- フォーマットパターンあり -->
<Meter
  value={75}
  label="Progress"
  format="{value}%"
/>

<!-- カスタム範囲 -->
<Meter
  value={3.5}
  min={0}
  max={5}
  label="Rating"
  format="{value} / {max}"
/>

<!-- スクリーンリーダー用の valueText -->
<Meter
  value={75}
  label="Download Progress"
  valueText="75 percent complete"
/>

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.svelte.ts
import { render, screen } from '@testing-library/svelte';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import Meter from './Meter.svelte';
import MeterWithLabel from './MeterWithLabel.test.svelte';

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

    it('has aria-valuenow set to current value', () => {
      render(Meter, {
        props: { 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, {
        props: { 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, {
        props: { 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, {
        props: { 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, {
        props: { 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, {
        props: { 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, {
        props: { value: 75, 'aria-label': 'Progress' },
      });
      const meter = screen.getByRole('meter');
      expect(meter).not.toHaveAttribute('aria-valuetext');
    });

    it('uses format for aria-valuetext', () => {
      render(Meter, {
        props: {
          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, {
        props: { value: 50, 'aria-label': 'CPU Usage' },
      });
      expect(screen.getByRole('meter', { name: 'CPU Usage' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(MeterWithLabel);
      expect(screen.getByRole('meter', { name: 'Battery Level' })).toBeInTheDocument();
    });

    it('has accessible name via visible label', () => {
      render(Meter, {
        props: { 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, {
        props: { 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, {
        props: { 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, {
        props: { 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, {
        props: { value: 50, 'aria-label': 'Progress' },
      });
      const meter = screen.getByRole('meter');
      expect(meter).not.toHaveAttribute('tabindex');
    });

    it('is focusable when tabindex is provided', () => {
      render(Meter, {
        props: { 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, {
        props: { 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, {
        props: { value: 50, label: 'CPU Usage' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

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

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal values correctly', () => {
      render(Meter, {
        props: { 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, {
        props: { 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');
    });
  });

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

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

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

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

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

    it('sets id attribute', () => {
      render(Meter, {
        props: { 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, {
        props: { value: 50, 'aria-label': 'Progress', 'data-testid': 'custom-meter' },
      });
      expect(screen.getByTestId('custom-meter')).toBeInTheDocument();
    });
  });
});

リソース