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.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)

テスト 説明
Same meter count React、Vue、Svelte、Astroすべてが同じ数のメーターをレンダリング
Consistent ARIA attributes すべてのフレームワークで一貫した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');
    });
  });
});

リソース