Browse Source
* feat(@vben-core/shadcn-ui): add Progress primitive with progressbar semantics Add a reka-ui-based Progress primitive (role="progressbar") exposing aria-valuemin/valuemax/valuenow, a custom max, and an indeterminate state when modelValue is null, with unit tests covering those semantics. Co-Authored-By: EvoX <evox@evomap.ai> * chore: add changeset for Progress primitive Co-Authored-By: EvoX <evox@evomap.ai> * fix(@vben-core/shadcn-ui): normalize progress indicator fill with custom max Co-Authored-By: EvoX <evox@evomap.ai> * fix(@vben-core/shadcn-ui): guard Progress against non-positive max values Co-Authored-By: EvoX <evox@evomap.ai> * chore(@vben-core/shadcn-ui): dedupe progress max fallback tests Co-Authored-By: EvoX <evox@evomap.ai> * test(@vben-core/shadcn-ui): assert NaN max fallback instead of zero Co-Authored-By: EvoX <evox@evomap.ai> * fix(@vben-core/shadcn-ui): reject non-finite progress max values Co-Authored-By: EvoX <evox@evomap.ai> * fix(@vben-core/shadcn-ui): narrow max to number in progress normalization Co-Authored-By: EvoX <evox@evomap.ai> --------- Co-authored-by: EvoX <evox@evomap.ai>pull/8317/head
committed by
GitHub
5 changed files with 170 additions and 0 deletions
@ -0,0 +1,5 @@ |
|||
--- |
|||
'@vben-core/shadcn-ui': minor |
|||
--- |
|||
|
|||
feat(@vben-core/shadcn-ui): add Progress primitive with progressbar semantics |
|||
@ -0,0 +1,58 @@ |
|||
<script setup lang="ts"> |
|||
import type { ProgressRootEmits, ProgressRootProps } from 'reka-ui'; |
|||
|
|||
import type { HTMLAttributes } from 'vue'; |
|||
|
|||
import { computed } from 'vue'; |
|||
|
|||
import { cn } from '@vben-core/shared/utils'; |
|||
|
|||
import { reactiveOmit } from '@vueuse/core'; |
|||
import { ProgressIndicator, ProgressRoot, useForwardPropsEmits } from 'reka-ui'; |
|||
|
|||
const props = defineProps< |
|||
ProgressRootProps & { class?: HTMLAttributes['class'] } |
|||
>(); |
|||
|
|||
const emits = defineEmits<ProgressRootEmits>(); |
|||
|
|||
/** ProgressRoot 对非法 max 的回退值(max 必须为正数)。 */ |
|||
const DEFAULT_MAX = 100; |
|||
|
|||
const delegatedProps = reactiveOmit(props, 'class'); |
|||
|
|||
const forwarded = useForwardPropsEmits(delegatedProps, emits); |
|||
|
|||
/** |
|||
* ProgressRoot 会把非法 max(0、负数、NaN、Infinity)回退为 100;这里用 |
|||
* 同一规则归一化后同时用于 root 与指示条 transform,避免 max=0 产生无穷 |
|||
* 百分比,也避免 max=Infinity 让指示条除以无穷而退化为空填充。 |
|||
*/ |
|||
const normalizedMax = computed(() => { |
|||
const { max } = props; |
|||
return typeof max === 'number' && Number.isFinite(max) && max > 0 |
|||
? max |
|||
: DEFAULT_MAX; |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<ProgressRoot |
|||
v-slot="slotProps" |
|||
data-slot="progress" |
|||
v-bind="forwarded" |
|||
:max="normalizedMax" |
|||
:class=" |
|||
cn( |
|||
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', |
|||
props.class, |
|||
) |
|||
" |
|||
> |
|||
<ProgressIndicator |
|||
data-slot="progress-indicator" |
|||
:class="cn('bg-primary h-full w-full flex-1 transition-all')" |
|||
:style="`transform: translateX(-${100 - ((slotProps.modelValue || 0) / normalizedMax) * 100}%)`" |
|||
/> |
|||
</ProgressRoot> |
|||
</template> |
|||
@ -0,0 +1,105 @@ |
|||
import type { App } from 'vue'; |
|||
|
|||
import { createApp, defineComponent, h, nextTick, ref } from 'vue'; |
|||
|
|||
import { afterEach, describe, expect, it } from 'vitest'; |
|||
|
|||
import Progress from '../Progress.vue'; |
|||
|
|||
let activeApp: App | undefined; |
|||
|
|||
const HOST_DEFAULT_MAX = 100; |
|||
|
|||
/** |
|||
* 挂载受控 Progress,验证 reka-ui 原语暴露的 ARIA 订单与确定/不确定状态语义。 |
|||
*/ |
|||
async function mountProgress( |
|||
modelValue: null | number, |
|||
max = HOST_DEFAULT_MAX, |
|||
) { |
|||
const value = ref(modelValue); |
|||
const Consumer = defineComponent( |
|||
() => () => |
|||
h(Progress, { |
|||
max, |
|||
modelValue: value.value, |
|||
}), |
|||
); |
|||
const host = document.createElement('div'); |
|||
document.body.append(host); |
|||
activeApp = createApp(Consumer); |
|||
activeApp.mount(host); |
|||
await nextTick(); |
|||
|
|||
const root = host.querySelector('[data-slot="progress"]'); |
|||
const indicator = host.querySelector('[data-slot="progress-indicator"]'); |
|||
|
|||
return { indicator, root, value }; |
|||
} |
|||
|
|||
afterEach(() => { |
|||
activeApp?.unmount(); |
|||
activeApp = undefined; |
|||
document.body.innerHTML = ''; |
|||
}); |
|||
|
|||
describe('vben progress', () => { |
|||
it('renders progressbar semantics with bounded value', async () => { |
|||
const { root } = await mountProgress(42); |
|||
|
|||
expect(root).toBeInstanceOf(HTMLElement); |
|||
if (!(root instanceof HTMLElement)) return; |
|||
expect(root.getAttribute('role')).toBe('progressbar'); |
|||
expect(root.getAttribute('aria-valuemin')).toBe('0'); |
|||
expect(root.getAttribute('aria-valuemax')).toBe('100'); |
|||
expect(root.getAttribute('aria-valuenow')).toBe('42'); |
|||
}); |
|||
|
|||
it('respects a custom max when reporting value', async () => { |
|||
const { indicator, root } = await mountProgress(30, 50); |
|||
|
|||
expect(root).toBeInstanceOf(HTMLElement); |
|||
if (!(root instanceof HTMLElement)) return; |
|||
expect(root.getAttribute('aria-valuemax')).toBe('50'); |
|||
expect(root.getAttribute('aria-valuenow')).toBe('30'); |
|||
|
|||
// 30 / 50 应按自定义 max 归一化为 60%,而不是 30%。
|
|||
expect(indicator).toBeInstanceOf(HTMLElement); |
|||
if (!(indicator instanceof HTMLElement)) return; |
|||
expect(indicator.style.transform).toBe('translateX(-40%)'); |
|||
}); |
|||
|
|||
it.each([ |
|||
['NaN', Number.NaN], |
|||
['infinite', Number.POSITIVE_INFINITY], |
|||
])('falls back to max 100 when a %s max is provided', async (_label, max) => { |
|||
const { indicator, root } = await mountProgress(30, max); |
|||
|
|||
expect(root).toBeInstanceOf(HTMLElement); |
|||
if (!(root instanceof HTMLElement)) return; |
|||
expect(root.getAttribute('aria-valuemax')).toBe('100'); |
|||
|
|||
// 非有限 max 不得产生无穷 transform:30 / 100 → 30% 填充。
|
|||
expect(indicator).toBeInstanceOf(HTMLElement); |
|||
if (!(indicator instanceof HTMLElement)) return; |
|||
expect(indicator.style.transform).toBe('translateX(-70%)'); |
|||
}); |
|||
|
|||
it('omits aria-valuenow and marks indeterminate state when value is null', async () => { |
|||
const { root } = await mountProgress(null); |
|||
|
|||
expect(root).toBeInstanceOf(HTMLElement); |
|||
if (!(root instanceof HTMLElement)) return; |
|||
expect(root.getAttribute('role')).toBe('progressbar'); |
|||
expect(root.getAttribute('aria-valuenow')).toBeNull(); |
|||
expect(root.getAttribute('data-state')).toBe('indeterminate'); |
|||
}); |
|||
|
|||
it('hides the indicator fill when value is zero', async () => { |
|||
const { indicator } = await mountProgress(0); |
|||
|
|||
expect(indicator).toBeInstanceOf(HTMLElement); |
|||
if (!(indicator instanceof HTMLElement)) return; |
|||
expect(indicator.style.transform).toBe('translateX(-100%)'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1 @@ |
|||
export { default as Progress } from './Progress.vue'; |
|||
Loading…
Reference in new issue