APG Patterns
English GitHub
English GitHub

Meter

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

🤖 AI Implementation Guide

デモ

Native 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.vue
<template>
  <div
    role="meter"
    :aria-valuenow="normalizedValue"
    :aria-valuemin="min"
    :aria-valuemax="max"
    :aria-valuetext="ariaValueText"
    :aria-label="label || $attrs['aria-label']"
    :aria-labelledby="$attrs['aria-labelledby']"
    :aria-describedby="$attrs['aria-describedby']"
    :class="['apg-meter', $attrs.class]"
    :id="$attrs.id"
    :tabindex="$attrs.tabindex"
    :data-testid="$attrs['data-testid']"
  >
    <span v-if="label" class="apg-meter-label" aria-hidden="true">
      {{ label }}
    </span>
    <div class="apg-meter-track" aria-hidden="true">
      <div class="apg-meter-fill" :style="{ width: `${percentage}%` }" />
    </div>
    <span v-if="showValue" class="apg-meter-value" aria-hidden="true">
      {{ displayText }}
    </span>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';

defineOptions({
  inheritAttrs: false,
});

export interface MeterProps {
  /** Current value */
  value: number;
  /** Minimum value (default: 0) */
  min?: number;
  /** Maximum value (default: 100) */
  max?: number;
  /** Clamp value to min/max range (default: true) */
  clamp?: boolean;
  /** Show value text (default: true) */
  showValue?: boolean;
  /** Visible label text */
  label?: string;
  /** Human-readable value text for aria-valuetext */
  valueText?: string;
  /** Format pattern for dynamic value display (e.g., "{value}%", "{value} of {max}") */
  format?: string;
}

const props = withDefaults(defineProps<MeterProps>(), {
  min: 0,
  max: 100,
  clamp: true,
  showValue: true,
  label: undefined,
  valueText: undefined,
  format: undefined,
});

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

// Format value helper
const 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 = computed(() => clampNumber(props.value, props.min, props.max, props.clamp));

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

const ariaValueText = computed(() => {
  if (props.valueText) return props.valueText;
  if (props.format)
    return formatValueText(normalizedValue.value, props.format, props.min, props.max);
  return undefined;
});

const displayText = computed(() => {
  if (props.valueText) return props.valueText;
  return formatValueText(normalizedValue.value, props.format, props.min, props.max);
});
</script>

使い方

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

<template>
  <div>
    <!-- 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"
    />
  </div>
</template>

API

プロパティ デフォルト 説明
value number 必須 メーターの現在の値
min number 0 最小値
max number 100 最大値
clamp boolean true 値を最小値/最大値の範囲にクランプするかどうか
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)

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

テストツール

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

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

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

    it('has aria-valuenow set to current value', () => {
      render(Meter, {
        props: { value: 75 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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}%',
        },
        attrs: { '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 },
        attrs: { 'aria-label': 'CPU Usage' },
      });
      expect(screen.getByRole('meter', { name: 'CPU Usage' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render({
        components: { Meter },
        template: `
          <div>
            <span id="meter-label">Battery Level</span>
            <Meter :value="80" aria-labelledby="meter-label" />
          </div>
        `,
      });
      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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { 'aria-label': 'Progress' },
      });
      expect(screen.getByText('75')).toBeInTheDocument();
    });

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

    it('displays formatted value when format provided', () => {
      render(Meter, {
        props: {
          value: 75,
          format: '{value}%',
        },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { 'aria-label': 'Progress', 'data-testid': 'custom-meter' },
      });
      expect(screen.getByTestId('custom-meter')).toBeInTheDocument();
    });
  });
});

リソース