メーター
定義された範囲内で変化する数値のグラフィカル表示。
デモ
ネイティブ 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> 要素は、値のしきい値に基づいて自動的に色を変更するための low、high、optimum 属性をサポートしています。この機能はカスタムコンポーネントでは手動実装が必要です。
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
meter |
コンテナ要素 | 既知の範囲内のスカラー値を表示するメーターとして要素を識別します。 |
WAI-ARIA プロパティ
aria-valuenow
aria-valueminとaria-valuemaxの間である必要があります
- 値
- 数値(現在の値)
- 必須
- はい
aria-valuemin
メーターの最小許容値を指定します
- 値
- 数値(デフォルト: 0)
- 必須
- はい
aria-valuemax
メーターの最大許容値を指定します
- 値
- 数値(デフォルト: 100)
- 必須
- はい
aria-valuetext
現在の値に対する人間が読みやすいテキストの代替を提供します。数値だけでは十分な意味を伝えられない場合に使用します。
- 値
- 文字列(例:
75% complete) - 必須
- いいえ
aria-label
メーターに見えないラベルを提供します
- 値
- 文字列
- 必須
- 条件付き(表示されるラベルがない場合は必須)
aria-labelledby
外部要素をラベルとして参照します
- 値
- ID参照
- 必須
- 条件付き(表示されるラベルが存在する場合は必須)
- メーターはアクセシブルな名前を持つ必要があります。これはlabelプロパティを使用した表示されるラベル、見えないラベルのためのaria-label、または外部要素を参照するaria-labelledbyによって提供できます。
- meterロールは、定義された範囲内の数値のグラフィカル表示に使用されます。インタラクティブではないため、デフォルトではフォーカスを受け取るべきではありません。
ビジュアルデザイン
- 視覚的な塗りつぶしバー - 現在の値を比例的に表現
- 数値表示 - 現在の値を示すオプションのテキスト
- 表示されるラベル - メーターの目的を識別するオプションのラベル
- 強制カラーモード - 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のいずれかが必須です。Custom Events
| イベント | Detail | 説明 |
|---|---|---|
valuechange | { value: number } | <code>updateValue()</code>によって値が更新されたときに発行される |
テスト
テストは、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構造 |
テストツール
- 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での自動アクセシビリティテスト
詳細はテスト戦略ガイドを参照してください。
/**
* Meter Astro Component Tests using Container API
*
* These tests verify the initial server-rendered HTML of Meter.astro.
* Web Component behavior (the updateValue method and the valuechange event)
* is covered by E2E tests in e2e/meter.spec.ts.
*
* @see https://docs.astro.build/en/reference/container-reference/
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Meter from './Meter.astro';
describe('Meter (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
async function renderMeter(props: Record<string, unknown>): Promise<Document> {
const html = await container.renderToString(Meter, { props });
return new JSDOM(html).window.document;
}
// 🔴 High Priority: HTML Structure
describe('HTML Structure', () => {
it('renders apg-meter custom element wrapper', async () => {
const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
expect(doc.querySelector('apg-meter')).not.toBeNull();
});
it('stores min, max and clamp as data attributes for the Web Component', async () => {
const doc = await renderMeter({
value: 50,
min: 10,
max: 200,
clamp: false,
'aria-label': 'Progress',
});
const wrapper = doc.querySelector('apg-meter');
expect(wrapper?.getAttribute('data-min')).toBe('10');
expect(wrapper?.getAttribute('data-max')).toBe('200');
expect(wrapper?.getAttribute('data-clamp')).toBe('false');
});
it('stores format as a data attribute for the Web Component', async () => {
const doc = await renderMeter({ value: 50, format: '{value}%', 'aria-label': 'Progress' });
expect(doc.querySelector('apg-meter')?.getAttribute('data-format')).toBe('{value}%');
});
});
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="meter"', async () => {
const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
expect(doc.querySelector('[role="meter"]')).not.toBeNull();
});
it('has aria-valuenow set to current value', async () => {
const doc = await renderMeter({ value: 75, 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuenow')).toBe('75');
});
it('has aria-valuemin="0" when min is not provided', async () => {
const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuemin')).toBe('0');
});
it('has aria-valuemin set to min', async () => {
const doc = await renderMeter({ value: 50, min: 10, 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuemin')).toBe('10');
});
it('has aria-valuemax="100" when max is not provided', async () => {
const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuemax')).toBe('100');
});
it('has aria-valuemax set to max', async () => {
const doc = await renderMeter({ value: 50, max: 200, 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuemax')).toBe('200');
});
it('has aria-valuetext when valueText is provided', async () => {
const doc = await renderMeter({
value: 75,
valueText: '75 percent complete',
'aria-label': 'Progress',
});
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuetext')).toBe('75 percent complete');
});
it('has aria-valuetext built from format with value, min and max', async () => {
const doc = await renderMeter({
value: 3.5,
min: 0,
max: 5,
format: '{value} / {max}',
'aria-label': 'Rating',
});
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuetext')).toBe('3.5 / 5');
});
it('prefers valueText over format for aria-valuetext', async () => {
const doc = await renderMeter({
value: 75,
valueText: 'Three quarters',
format: '{value}%',
'aria-label': 'Progress',
});
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuetext')).toBe('Three quarters');
});
it('does not have aria-valuetext when neither valueText nor format is provided', async () => {
const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.hasAttribute('aria-valuetext')).toBe(false);
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', async () => {
const doc = await renderMeter({ value: 50, 'aria-label': 'CPU Usage' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-label')).toBe('CPU Usage');
});
it('has accessible name via aria-labelledby', async () => {
const doc = await renderMeter({ value: 50, 'aria-labelledby': 'meter-label' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-labelledby')).toBe('meter-label');
});
it('uses the visible label as aria-label', async () => {
const doc = await renderMeter({ value: 50, label: 'Storage Used' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-label')).toBe('Storage Used');
});
it('has aria-describedby when provided', async () => {
const doc = await renderMeter({
value: 50,
'aria-label': 'Progress',
'aria-describedby': 'meter-help',
});
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-describedby')).toBe('meter-help');
});
});
// 🔴 High Priority: Value Clamping
describe('Value Clamping', () => {
it('clamps value above max to max', async () => {
const doc = await renderMeter({ value: 150, min: 0, max: 100, 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuenow')).toBe('100');
});
it('clamps value below min to min', async () => {
const doc = await renderMeter({ value: -50, min: 0, max: 100, 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuenow')).toBe('0');
});
it('does not clamp when clamp is false', async () => {
const doc = await renderMeter({
value: 150,
min: 0,
max: 100,
clamp: false,
'aria-label': 'Progress',
});
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuenow')).toBe('150');
});
});
// 🔴 High Priority: Focus Behavior
describe('Focus Behavior', () => {
it('is not focusable (no tabindex)', async () => {
const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.hasAttribute('tabindex')).toBe(false);
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('renders decimal values as-is', async () => {
const doc = await renderMeter({ value: 33.33, 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuenow')).toBe('33.33');
});
it('renders a negative min/max range', async () => {
const doc = await renderMeter({ value: 0, min: -50, max: 50, 'aria-label': 'Temperature' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.getAttribute('aria-valuenow')).toBe('0');
expect(meter?.getAttribute('aria-valuemin')).toBe('-50');
expect(meter?.getAttribute('aria-valuemax')).toBe('50');
});
it('renders 0% fill width when min equals max', async () => {
const doc = await renderMeter({ value: 10, min: 10, max: 10, 'aria-label': 'Progress' });
const fill = doc.querySelector<HTMLElement>('.apg-meter-fill');
expect(fill?.style.width).toBe('0%');
});
});
// 🟡 Medium Priority: Visual Display
describe('Visual Display', () => {
it('shows the value when showValue is not specified', async () => {
const doc = await renderMeter({ value: 75, 'aria-label': 'Progress' });
expect(doc.querySelector('.apg-meter-value')?.textContent?.trim()).toBe('75');
});
it('shows the formatted value when format is provided', async () => {
const doc = await renderMeter({ value: 75, format: '{value}%', 'aria-label': 'Progress' });
expect(doc.querySelector('.apg-meter-value')?.textContent?.trim()).toBe('75%');
});
it('shows valueText instead of the raw value when valueText is provided', async () => {
const doc = await renderMeter({
value: -10,
valueText: '-10°C',
'aria-label': 'Temperature',
});
expect(doc.querySelector('.apg-meter-value')?.textContent?.trim()).toBe('-10°C');
});
it('does not render the value display when showValue is false', async () => {
const doc = await renderMeter({ value: 75, showValue: false, 'aria-label': 'Progress' });
expect(doc.querySelector('.apg-meter-value')).toBeNull();
});
it('displays the visible label when label is provided', async () => {
const doc = await renderMeter({ value: 50, label: 'CPU Usage' });
expect(doc.querySelector('.apg-meter-label')?.textContent?.trim()).toBe('CPU Usage');
});
it('hides the visual label, track and value from assistive technology', async () => {
const doc = await renderMeter({ value: 50, label: 'CPU Usage' });
expect(doc.querySelector('.apg-meter-label')?.getAttribute('aria-hidden')).toBe('true');
expect(doc.querySelector('.apg-meter-track')?.getAttribute('aria-hidden')).toBe('true');
expect(doc.querySelector('.apg-meter-value')?.getAttribute('aria-hidden')).toBe('true');
});
it('has fill width based on value', async () => {
const doc = await renderMeter({ value: 75, min: 0, max: 100, 'aria-label': 'Progress' });
const fill = doc.querySelector<HTMLElement>('.apg-meter-fill');
expect(fill?.style.width).toBe('75%');
});
it('has fill width relative to a non-zero min', async () => {
const doc = await renderMeter({ value: -10, min: -50, max: 50, 'aria-label': 'Temperature' });
const fill = doc.querySelector<HTMLElement>('.apg-meter-fill');
expect(fill?.style.width).toBe('40%');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attributes', () => {
it('sets the id attribute on the meter', async () => {
const doc = await renderMeter({ value: 50, id: 'my-meter', 'aria-label': 'Progress' });
expect(doc.querySelector('[role="meter"]')?.id).toBe('my-meter');
});
it('applies a custom class to the meter', async () => {
const doc = await renderMeter({ value: 50, class: 'custom-class', 'aria-label': 'Progress' });
const meter = doc.querySelector('[role="meter"]');
expect(meter?.classList.contains('apg-meter')).toBe(true);
expect(meter?.classList.contains('custom-class')).toBe(true);
});
});
});リソース
- 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