Files
oa/src/components/base/VRangeRating.vue
2025-05-24 01:49:48 +09:00

223 lines
4.6 KiB
Vue

<script setup lang="ts">
const modelValue = defineModel<number | undefined>({
default: undefined,
})
const props = withDefaults(
defineProps<{
id?: string
max?: number
label?: string
size?: 'small' | 'base' | 'medium' | 'large' | 'xlarge'
readonly?: boolean
disabled?: boolean
}>(),
{
id: undefined,
max: 5,
size: 'base',
label: undefined,
readonly: undefined,
},
)
const { field, id } = useVFieldContext({
help: 'VRangeRating',
id: props.id,
})
const hasValue = computed(
() => field?.value !== undefined || modelValue.value !== undefined,
)
const active = computed(() => !props.readonly && hasValue.value)
const internal = computed({
get() {
if (field?.value) {
return field.value.value ?? 0
}
else {
return modelValue.value ?? 0
}
},
set(value: any) {
if (field?.value) {
field.value.setValue(value)
}
modelValue.value = value
},
})
const sizeStyle = computed(() => {
switch (props.size) {
case 'small':
return 'is-size-6'
case 'base':
return 'is-size-5'
case 'medium':
return 'is-size-4'
case 'large':
return 'is-size-3'
case 'xlarge':
return 'is-size-2'
}
})
const radiogroup = ref()
function focus() {
if (props.readonly) return
if (props.disabled) return
if (radiogroup.value) {
radiogroup.value.focus()
}
}
const wrapper = ref()
const { focused } = useFocusWithin(wrapper)
onKeyStroke('ArrowLeft', (e) => {
if (!focused.value) return
if (props.disabled) return
e.preventDefault()
if (internal.value > 0) {
internal.value = internal.value - 1
}
})
onKeyStroke('ArrowRight', (e) => {
if (!focused.value) return
if (props.disabled) return
e.preventDefault()
if (internal.value < props.max) {
internal.value = internal.value + 1
}
})
const highlighted = ref<number>()
function highlightIndex(index: number) {
if (props.readonly) return
highlighted.value = index + 1
}
function unhighlight() {
highlighted.value = undefined
}
function selectIndex(index: number) {
if (props.readonly) return
if (props.disabled) return
internal.value = index + 1
}
function isStarSelected(index: number) {
if (!hasValue.value) return 0
if (highlighted.value !== undefined) {
return highlighted.value - index > 0
}
return internal.value - index > 0
}
</script>
<template>
<div
ref="wrapper"
class="rating-wrap"
>
<div
v-if="props.label || 'label' in $slots"
class="rating-label"
>
<slot name="label">
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<label
:id="`${id}-label`"
:for="id"
@click="focus"
@keydown.enter="focus"
>
{{ props.label }}
</label>
</slot>
</div>
<div
:id="id"
ref="radiogroup"
class="rating"
:class="{
'is-active': active,
'is-highlighted': highlighted,
[sizeStyle]: true,
}"
:aria-labelledby="
props.label || ('label' in $slots && active) ? `${id}-label` : undefined
"
:tabindex="active ? 0 : undefined"
:role="active ? 'radiogroup' : undefined"
>
<span
v-for="(star, index) in props.max"
:key="index"
:role="active ? 'radio' : undefined"
:aria-label="String(index + 1)"
:aria-checked="active ? internal - index > 0 : undefined"
aria-hidden="true"
class="rating-star"
:class="{
'is-rating-star-selected': isStarSelected(index),
}"
@pointerenter.passive="() => highlightIndex(index)"
@pointerleave.passive="() => unhighlight()"
@click.passive="() => selectIndex(index)"
>
<slot
v-bind="{
value: index + 1,
isSelected: isStarSelected(index),
isHighlighted: index + 1 === highlighted,
}"
>
<VIcon icon="ph:star-fill" />
</slot>
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.rating-wrap {
text-align: inset-inline-end;
.rating-label {
color: var(--light-text);
}
.rating {
display: inline-flex;
.rating-star {
color: color-mix(in oklab, var(--widget-grey), black 8%);
&.is-rating-star-selected {
color: var(--yellow) !important;
}
}
&.is-active {
[role='radio'] {
cursor: pointer;
}
}
}
}
.is-dark .rating-wrap {
.rating {
.rating-star {
color: color-mix(in oklab, var(--dark-sidebar), white 22%);
}
}
}
</style>