Skip to content

Commit a978051

Browse files
author
Lasim
committed
feat(frontend): implement meter component with accessibility features
1 parent a6e7176 commit a978051

File tree

6 files changed

+226
-0
lines changed

6 files changed

+226
-0
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { computed, provide, ref } from 'vue'
4+
import { cn } from '@/lib/utils'
5+
6+
export interface MeterProps {
7+
/** Current value of the meter */
8+
value: number
9+
/** Minimum value (default: 0) */
10+
min?: number
11+
/** Maximum value (default: 100) */
12+
max?: number
13+
/** Custom locale for formatting (e.g., 'en-US', 'de-DE') */
14+
locale?: string
15+
/** Intl.NumberFormat options for custom formatting */
16+
format?: Intl.NumberFormatOptions
17+
/** Custom function to generate aria-valuetext */
18+
getAriaValueText?: (value: number, min: number, max: number) => string
19+
/** Additional CSS classes */
20+
class?: HTMLAttributes['class']
21+
}
22+
23+
const props = withDefaults(defineProps<MeterProps>(), {
24+
min: 0,
25+
max: 100,
26+
locale: 'en-US',
27+
})
28+
29+
// Label ID management for accessibility
30+
const labelId = ref<string | undefined>(undefined)
31+
32+
// Format the value for display
33+
const formattedValue = computed(() => {
34+
if (props.format) {
35+
return new Intl.NumberFormat(props.locale, props.format).format(props.value)
36+
}
37+
// Default: treat as percentage
38+
return new Intl.NumberFormat(props.locale, {
39+
style: 'percent',
40+
maximumFractionDigits: 0,
41+
}).format(props.value / 100)
42+
})
43+
44+
// Generate aria-valuetext
45+
const ariaValueText = computed(() => {
46+
if (props.getAriaValueText) {
47+
return props.getAriaValueText(props.value, props.min, props.max)
48+
}
49+
return formattedValue.value
50+
})
51+
52+
// Provide context for child components
53+
provide('meter', {
54+
value: computed(() => props.value),
55+
min: computed(() => props.min),
56+
max: computed(() => props.max),
57+
formattedValue,
58+
labelId,
59+
setLabelId: (id: string | undefined) => {
60+
labelId.value = id
61+
},
62+
})
63+
</script>
64+
65+
<template>
66+
<div
67+
role="meter"
68+
:aria-valuenow="value"
69+
:aria-valuemin="min"
70+
:aria-valuemax="max"
71+
:aria-valuetext="ariaValueText"
72+
:aria-labelledby="labelId"
73+
:class="cn('relative', props.class)"
74+
>
75+
<slot />
76+
</div>
77+
</template>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { computed, inject } from 'vue'
4+
import { cn } from '@/lib/utils'
5+
6+
export interface MeterIndicatorProps {
7+
/** Additional CSS classes */
8+
class?: HTMLAttributes['class']
9+
}
10+
11+
const props = defineProps<MeterIndicatorProps>()
12+
13+
// Inject meter context
14+
const meterContext = inject<{
15+
value: { value: number }
16+
min: { value: number }
17+
max: { value: number }
18+
}>('meter')
19+
20+
if (!meterContext) {
21+
throw new Error('MeterIndicator must be used within a Meter component')
22+
}
23+
24+
// Calculate percentage width
25+
const percentageWidth = computed(() => {
26+
const { value, min, max } = meterContext
27+
return ((value.value - min.value) * 100) / (max.value - min.value)
28+
})
29+
</script>
30+
31+
<template>
32+
<div
33+
:class="cn('h-full bg-primary transition-all', props.class)"
34+
:style="{
35+
width: `${percentageWidth}%`,
36+
}"
37+
/>
38+
</template>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { inject, onMounted, onUnmounted } from 'vue'
4+
import { useId } from 'reka-ui'
5+
import { cn } from '@/lib/utils'
6+
7+
export interface MeterLabelProps {
8+
/** Optional ID for the label (auto-generated if not provided) */
9+
id?: string
10+
/** Additional CSS classes */
11+
class?: HTMLAttributes['class']
12+
}
13+
14+
const props = defineProps<MeterLabelProps>()
15+
16+
// Inject meter context
17+
const meterContext = inject<{
18+
setLabelId: (id: string | undefined) => void
19+
}>('meter')
20+
21+
if (!meterContext) {
22+
throw new Error('MeterLabel must be used within a Meter component')
23+
}
24+
25+
// Generate or use provided ID
26+
const labelId = props.id || useId()
27+
28+
// Register label ID with meter on mount
29+
onMounted(() => {
30+
meterContext.setLabelId(labelId)
31+
})
32+
33+
// Cleanup on unmount
34+
onUnmounted(() => {
35+
meterContext.setLabelId(undefined)
36+
})
37+
</script>
38+
39+
<template>
40+
<span
41+
:id="labelId"
42+
:class="cn('text-sm font-medium leading-none', props.class)"
43+
>
44+
<slot />
45+
</span>
46+
</template>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { cn } from '@/lib/utils'
4+
5+
export interface MeterTrackProps {
6+
/** Additional CSS classes */
7+
class?: HTMLAttributes['class']
8+
}
9+
10+
const props = defineProps<MeterTrackProps>()
11+
</script>
12+
13+
<template>
14+
<div
15+
:class="
16+
cn(
17+
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
18+
props.class,
19+
)
20+
"
21+
>
22+
<slot />
23+
</div>
24+
</template>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { inject } from 'vue'
4+
import { cn } from '@/lib/utils'
5+
6+
export interface MeterValueProps {
7+
/** Additional CSS classes */
8+
class?: HTMLAttributes['class']
9+
}
10+
11+
const props = defineProps<MeterValueProps>()
12+
13+
// Inject meter context
14+
const meterContext = inject<{
15+
value: { value: number }
16+
formattedValue: { value: string }
17+
}>('meter')
18+
19+
if (!meterContext) {
20+
throw new Error('MeterValue must be used within a Meter component')
21+
}
22+
</script>
23+
24+
<template>
25+
<span
26+
aria-hidden="true"
27+
:class="cn('text-sm font-medium', props.class)"
28+
>
29+
<slot
30+
:formatted-value="meterContext.formattedValue.value"
31+
:value="meterContext.value.value"
32+
>
33+
{{ meterContext.formattedValue.value }}
34+
</slot>
35+
</span>
36+
</template>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export { default as DsMeter } from './Meter.vue'
2+
export { default as DsMeterTrack } from './MeterTrack.vue'
3+
export { default as DsMeterIndicator } from './MeterIndicator.vue'
4+
export { default as DsMeterLabel } from './MeterLabel.vue'
5+
export { default as DsMeterValue } from './MeterValue.vue'

0 commit comments

Comments
 (0)