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.astro
---
/**
 * APG Meter Pattern - Astro Implementation
 *
 * A graphical display of a numeric value within a defined range.
 * Uses Web Components for dynamic value updates.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/meter/
 */

export interface Props {
  /** 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;
  /** Meter id */
  id?: string;
  /** Additional CSS class */
  class?: string;
  /** Accessible label when no visible label */
  'aria-label'?: string;
  /** Reference to external label element */
  'aria-labelledby'?: string;
  /** Reference to description element */
  'aria-describedby'?: string;
}

const {
  value,
  min = 0,
  max = 100,
  clamp = true,
  showValue = true,
  label,
  valueText,
  format,
  id,
  class: className = '',
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  'aria-describedby': ariaDescribedby,
} = Astro.props;

// Clamp value to min/max range
const 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;
};

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

// Calculate percentage for visual display
const percentage =
  max === min ? 0 : Math.max(0, Math.min(100, ((normalizedValue - min) / (max - min)) * 100));

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

// Display text (valueText takes priority, then format, then raw value)
const displayText = valueText ?? formatValueText(normalizedValue, format);

// aria-valuetext (valueText or format, not raw value)
const ariaValueText = valueText ?? (format ? formatValueText(normalizedValue, format) : undefined);
---

<apg-meter data-value={value} data-min={min} data-max={max} data-clamp={clamp} data-format={format}>
  <div
    role="meter"
    aria-valuenow={normalizedValue}
    aria-valuemin={min}
    aria-valuemax={max}
    aria-valuetext={ariaValueText}
    aria-label={label || ariaLabel}
    aria-labelledby={ariaLabelledby}
    aria-describedby={ariaDescribedby}
    id={id}
    class={`apg-meter ${className}`.trim()}
  >
    {
      label && (
        <span 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>
    </div>
    {
      showValue && (
        <span class="apg-meter-value" aria-hidden="true">
          {displayText}
        </span>
      )
    }
  </div>
</apg-meter>

<script>
  class ApgMeter extends HTMLElement {
    private meter: HTMLElement | null = null;
    private fill: HTMLElement | null = null;
    private valueDisplay: HTMLElement | null = null;

    connectedCallback() {
      this.meter = this.querySelector('[role="meter"]');
      this.fill = this.querySelector('.apg-meter-fill');
      this.valueDisplay = this.querySelector('.apg-meter-value');
    }

    private get format(): string | undefined {
      return this.dataset.format;
    }

    private formatValue(value: number, min: number, max: number): string {
      const fmt = this.format;
      if (!fmt) return String(value);
      return fmt
        .replace('{value}', String(value))
        .replace('{min}', String(min))
        .replace('{max}', String(max));
    }

    // Method to update value dynamically
    updateValue(newValue: number) {
      if (!this.meter) return;

      const min = Number(this.dataset.min) || 0;
      const max = Number(this.dataset.max) || 100;
      const clamp = this.dataset.clamp !== 'false';

      // Clamp if needed
      let normalizedValue = newValue;
      if (clamp && Number.isFinite(newValue)) {
        normalizedValue = Math.min(max, Math.max(min, newValue));
      }

      // Update ARIA attributes
      this.meter.setAttribute('aria-valuenow', String(normalizedValue));

      // Update aria-valuetext if format is provided
      const formattedValue = this.formatValue(normalizedValue, min, max);
      if (this.format) {
        this.meter.setAttribute('aria-valuetext', formattedValue);
      }

      // Update visual fill
      if (this.fill) {
        const percentage = max === min ? 0 : ((normalizedValue - min) / (max - min)) * 100;
        this.fill.style.width = `${Math.max(0, Math.min(100, percentage))}%`;
      }

      // Update value display
      if (this.valueDisplay) {
        this.valueDisplay.textContent = formattedValue;
      }

      // Dispatch event
      this.dispatchEvent(
        new CustomEvent('valuechange', {
          detail: { value: normalizedValue },
          bubbles: true,
        })
      );
    }
  }

  if (!customElements.get('apg-meter')) {
    customElements.define('apg-meter', ApgMeter);
  }
</script>

使い方

Example
---
import Meter from './Meter.astro';
---

<!-- Basic usage with aria-label -->
<Meter value={75} aria-label="CPU Usage" />

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

<!-- With format pattern -->
<Meter
  value={75}
  label="Progress"
  format="{value}%"
/>

<!-- Custom range -->
<Meter
  value={3.5}
  min={0}
  max={5}
  label="Rating"
  format="{value} / {max}"
/>

<!-- With valueText for screen readers -->
<Meter
  value={-10}
  min={-50}
  max={50}
  label="Temperature"
  valueText="-10°C"
/>

<!-- Dynamic updates via Web Component API -->
<Meter value={50} id="my-meter" label="Download" format="{value}%" />
<script>
  const meter = document.querySelector('#my-meter').closest('apg-meter');
  meter.updateValue(75);
</script>

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-labelaria-labelledbyのいずれかが必須です。

Web Component メソッド

メソッド 説明
updateValue(value: number) メーターの値を動的に更新する

カスタムイベント

イベント Detail
valuechange { value: number } - updateValue()によって値が更新されたときに発行される

テスト

テストは、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.astro.ts
/**
 * Meter Web Component Tests
 *
 * Unit tests for the Web Component class.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('Meter (Web Component)', () => {
  let container: HTMLElement;

  // Web Component class extracted for testing
  class TestApgMeter extends HTMLElement {
    private meter: HTMLElement | null = null;
    private fill: HTMLElement | null = null;
    private valueDisplay: HTMLElement | null = null;

    connectedCallback() {
      this.meter = this.querySelector('[role="meter"]');
      this.fill = this.querySelector('.apg-meter-fill');
      this.valueDisplay = this.querySelector('.apg-meter-value');
    }

    updateValue(newValue: number) {
      if (!this.meter) return;

      const min = Number(this.dataset.min) || 0;
      const max = Number(this.dataset.max) || 100;
      const clamp = this.dataset.clamp !== 'false';

      let normalizedValue = newValue;
      if (clamp && Number.isFinite(newValue)) {
        normalizedValue = Math.min(max, Math.max(min, newValue));
      }

      this.meter.setAttribute('aria-valuenow', String(normalizedValue));

      if (this.fill) {
        const percentage = max === min ? 0 : ((normalizedValue - min) / (max - min)) * 100;
        this.fill.style.width = `${Math.max(0, Math.min(100, percentage))}%`;
      }

      if (this.valueDisplay) {
        this.valueDisplay.textContent = String(normalizedValue);
      }

      this.dispatchEvent(
        new CustomEvent('valuechange', {
          detail: { value: normalizedValue },
          bubbles: true,
        })
      );
    }

    // Expose for testing
    get _meter() {
      return this.meter;
    }
    get _fill() {
      return this.fill;
    }
    get _valueDisplay() {
      return this.valueDisplay;
    }
  }

  function createMeterHTML(
    options: {
      value?: number;
      min?: number;
      max?: number;
      clamp?: boolean;
      showValue?: boolean;
      label?: string;
      valueText?: string;
      id?: string;
      ariaLabel?: string;
      ariaLabelledby?: string;
    } = {}
  ) {
    const {
      value = 50,
      min = 0,
      max = 100,
      clamp = true,
      showValue = true,
      label,
      valueText,
      id,
      ariaLabel = 'Progress',
      ariaLabelledby,
    } = options;

    // Calculate normalized value
    let normalizedValue = value;
    if (clamp && Number.isFinite(value)) {
      normalizedValue = Math.min(max, Math.max(min, value));
    }

    const percentage = max === min ? 0 : ((normalizedValue - min) / (max - min)) * 100;

    return `
      <apg-meter
        class="apg-meter"
        data-value="${value}"
        data-min="${min}"
        data-max="${max}"
        data-clamp="${clamp}"
      >
        <div
          role="meter"
          aria-valuenow="${normalizedValue}"
          aria-valuemin="${min}"
          aria-valuemax="${max}"
          ${valueText ? `aria-valuetext="${valueText}"` : ''}
          ${ariaLabelledby ? `aria-labelledby="${ariaLabelledby}"` : `aria-label="${label || ariaLabel}"`}
          ${id ? `id="${id}"` : ''}
          class="apg-meter-container"
        >
          ${label ? `<span 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>
          </div>
          ${showValue ? `<span class="apg-meter-value" aria-hidden="true">${normalizedValue}</span>` : ''}
        </div>
      </apg-meter>
    `;
  }

  beforeEach(() => {
    // Register custom element if not already registered
    if (!customElements.get('apg-meter')) {
      customElements.define('apg-meter', TestApgMeter);
    }

    container = document.createElement('div');
    document.body.appendChild(container);
  });

  afterEach(() => {
    container.remove();
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="meter"', () => {
      container.innerHTML = createMeterHTML();
      const meter = container.querySelector('[role="meter"]');
      expect(meter).not.toBeNull();
    });

    it('has aria-valuenow set to current value', () => {
      container.innerHTML = createMeterHTML({ value: 75 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('75');
    });

    it('has aria-valuemin set', () => {
      container.innerHTML = createMeterHTML({ min: 10 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuemin')).toBe('10');
    });

    it('has aria-valuemax set', () => {
      container.innerHTML = createMeterHTML({ max: 200 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuemax')).toBe('200');
    });

    it('has aria-valuetext when provided', () => {
      container.innerHTML = createMeterHTML({ valueText: '75 percent complete' });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuetext')).toBe('75 percent complete');
    });

    it('does not have aria-valuetext when not provided', () => {
      container.innerHTML = createMeterHTML();
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.hasAttribute('aria-valuetext')).toBe(false);
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      container.innerHTML = createMeterHTML({ ariaLabel: 'CPU Usage' });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-label')).toBe('CPU Usage');
    });

    it('has accessible name via aria-labelledby', () => {
      container.innerHTML = `
        <span id="meter-label">Battery Level</span>
        ${createMeterHTML({ ariaLabelledby: 'meter-label', ariaLabel: undefined })}
      `;
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-labelledby')).toBe('meter-label');
    });

    it('has accessible name via visible label', () => {
      container.innerHTML = createMeterHTML({ label: 'Storage Used' });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-label')).toBe('Storage Used');
    });
  });

  // 🔴 High Priority: Value Clamping
  describe('Value Clamping', () => {
    it('clamps value above max to max', () => {
      container.innerHTML = createMeterHTML({ value: 150, min: 0, max: 100 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('100');
    });

    it('clamps value below min to min', () => {
      container.innerHTML = createMeterHTML({ value: -50, min: 0, max: 100 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('0');
    });

    it('does not clamp when clamp=false', () => {
      container.innerHTML = createMeterHTML({ value: 150, min: 0, max: 100, clamp: false });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('150');
    });
  });

  // 🔴 High Priority: Focus Behavior
  describe('Focus Behavior', () => {
    it('is not focusable by default', () => {
      container.innerHTML = createMeterHTML();
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.hasAttribute('tabindex')).toBe(false);
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal values correctly', () => {
      container.innerHTML = createMeterHTML({ value: 33.33 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('33.33');
    });

    it('handles negative min/max range', () => {
      container.innerHTML = createMeterHTML({ value: 0, min: -50, max: 50 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('0');
      expect(meter?.getAttribute('aria-valuemin')).toBe('-50');
      expect(meter?.getAttribute('aria-valuemax')).toBe('50');
    });
  });

  // 🟡 Medium Priority: Visual Display
  describe('Visual Display', () => {
    it('shows value when showValue is true (default)', () => {
      container.innerHTML = createMeterHTML({ value: 75 });
      const valueDisplay = container.querySelector('.apg-meter-value');
      expect(valueDisplay?.textContent).toBe('75');
    });

    it('hides value when showValue is false', () => {
      container.innerHTML = createMeterHTML({ value: 75, showValue: false });
      const valueDisplay = container.querySelector('.apg-meter-value');
      expect(valueDisplay).toBeNull();
    });

    it('displays visible label when label provided', () => {
      container.innerHTML = createMeterHTML({ label: 'CPU Usage' });
      const labelDisplay = container.querySelector('.apg-meter-label');
      expect(labelDisplay?.textContent).toBe('CPU Usage');
    });

    it('has correct fill width based on value', () => {
      container.innerHTML = createMeterHTML({ value: 75, min: 0, max: 100 });
      const fill = container.querySelector('.apg-meter-fill') as HTMLElement;
      expect(fill?.style.width).toBe('75%');
    });
  });

  // 🟡 Medium Priority: Dynamic Updates
  describe('Dynamic Updates', () => {
    it('updates aria-valuenow when updateValue is called', async () => {
      container.innerHTML = createMeterHTML({ value: 50 });
      const component = container.querySelector('apg-meter') as TestApgMeter;

      // Wait for connectedCallback
      await new Promise((resolve) => requestAnimationFrame(resolve));

      component.updateValue(75);

      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('75');
    });

    it('clamps value on updateValue when clamp=true', async () => {
      container.innerHTML = createMeterHTML({ value: 50, clamp: true });
      const component = container.querySelector('apg-meter') as TestApgMeter;

      await new Promise((resolve) => requestAnimationFrame(resolve));

      component.updateValue(150);

      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('100');
    });

    it('dispatches valuechange event on update', async () => {
      container.innerHTML = createMeterHTML({ value: 50 });
      const component = container.querySelector('apg-meter') as TestApgMeter;

      await new Promise((resolve) => requestAnimationFrame(resolve));

      const handler = vi.fn();
      component.addEventListener('valuechange', handler);

      component.updateValue(75);

      expect(handler).toHaveBeenCalledTimes(1);
      expect(handler).toHaveBeenCalledWith(
        expect.objectContaining({
          detail: { value: 75 },
        })
      );
    });

    it('updates visual fill on updateValue', async () => {
      container.innerHTML = createMeterHTML({ value: 50 });
      const component = container.querySelector('apg-meter') as TestApgMeter;

      await new Promise((resolve) => requestAnimationFrame(resolve));

      component.updateValue(75);

      const fill = container.querySelector('.apg-meter-fill') as HTMLElement;
      expect(fill?.style.width).toBe('75%');
    });

    it('updates value display on updateValue', async () => {
      container.innerHTML = createMeterHTML({ value: 50 });
      const component = container.querySelector('apg-meter') as TestApgMeter;

      await new Promise((resolve) => requestAnimationFrame(resolve));

      component.updateValue(75);

      const valueDisplay = container.querySelector('.apg-meter-value');
      expect(valueDisplay?.textContent).toBe('75');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('sets id attribute', () => {
      container.innerHTML = createMeterHTML({ id: 'my-meter' });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('id')).toBe('my-meter');
    });
  });
});

リソース