Browse Source

feat(@vben-core/shadcn-ui): add Progress primitive with progressbar semantics (#8305)

* 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
俞可 · YU KE 1 month ago
committed by GitHub
parent
commit
142b5442c2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/olive-foxes-progress-meter.md
  2. 1
      packages/@core/ui-kit/shadcn-ui/src/ui/index.ts
  3. 58
      packages/@core/ui-kit/shadcn-ui/src/ui/progress/Progress.vue
  4. 105
      packages/@core/ui-kit/shadcn-ui/src/ui/progress/__tests__/progress.test.ts
  5. 1
      packages/@core/ui-kit/shadcn-ui/src/ui/progress/index.ts

5
.changeset/olive-foxes-progress-meter.md

@ -0,0 +1,5 @@
---
'@vben-core/shadcn-ui': minor
---
feat(@vben-core/shadcn-ui): add Progress primitive with progressbar semantics

1
packages/@core/ui-kit/shadcn-ui/src/ui/index.ts

@ -16,6 +16,7 @@ export * from './number-field';
export * from './pagination';
export * from './pin-input';
export * from './popover';
export * from './progress';
export * from './radio-group';
export * from './resizable';
export * from './scroll-area';

58
packages/@core/ui-kit/shadcn-ui/src/ui/progress/Progress.vue

@ -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>

105
packages/@core/ui-kit/shadcn-ui/src/ui/progress/__tests__/progress.test.ts

@ -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%)');
});
});

1
packages/@core/ui-kit/shadcn-ui/src/ui/progress/index.ts

@ -0,0 +1 @@
export { default as Progress } from './Progress.vue';
Loading…
Cancel
Save