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-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ハイコントラストモードでのアクセシビリティのためにシステムカラーを使用
参考資料
ソースコード
<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}") |
アクセシビリティのため、label、aria-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構造 |
テストツール
- 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) を参照してください。
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();
});
});
}); リソース
- 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