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-valuemin と aria-valuemax の間である必要があります
|
aria-valuemin
メーターの最小許容値を指定します。
| 型 | 数値 |
| 必須 | はい |
| デフォルト | 0 |
aria-valuemax
メーターの最大許容値を指定します。
| 型 | 数値 |
| 必須 | はい |
| デフォルト | 100 |
aria-valuetext
現在の値に対する人間が読みやすいテキストの代替を提供します。数値だけでは十分な意味を伝えられない場合に使用します。
| 型 | 文字列 |
| 必須 | いいえ(値が文脈を必要とする場合は推奨) |
| 例 | "75% complete", "3 out of 4 GB used" |
キーボードサポート
該当なし。 メーターパターンは表示専用の要素であり、インタラクティブではありません。 特定のユースケースのために明示的にフォーカス可能にされない限り、キーボードフォーカスを受け取るべきではありません。
アクセシブルな名前
メーターはアクセシブルな名前を持つ必要があります。これは以下の方法で提供できます:
- 表示されるラベル -
labelプロパティを使用して表示されるラベルを提供 -
aria-label- メーターに見えないラベルを提供 -
aria-labelledby- 外部要素をラベルとして参照
ビジュアルデザイン
この実装は、アクセシブルなビジュアルデザインのためのWCAGガイドラインに従っています:
- 視覚的な塗りつぶしバー - 現在の値を比例的に表現
- 数値表示 - 現在の値を示すオプションのテキスト
- 表示されるラベル - メーターの目的を識別するオプションのラベル
- 強制カラーモード - Windowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用
参考資料
ソースコード
---
/**
* 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> 使い方
---
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}") |
アクセシビリティのため、label、aria-label、aria-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構造
テストツール
- Vitest (opens in new tab)
- ユニットテストランナー
- Testing Library (opens in new tab)
- フレームワーク別テストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab)
- E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab)
- E2Eでの自動アクセシビリティテスト
完全なドキュメントについては 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');
});
});
});
リソース
-
WAI-ARIA APG: Meter パターン
(opens in new tab)
-
MDN: <meter> 要素
(opens in new tab)
-
AI Implementation Guide (llm.md)
(opens in new tab) - ARIA specs, keyboard support, test checklist