From 142b5442c2270090720a92671a0573cfe6974fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9E=E5=8F=AF=20=C2=B7=20YU=20KE?= <1798456934@qq.com> Date: Fri, 28 Aug 2026 15:41:32 +0800 Subject: [PATCH] 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 * chore: add changeset for Progress primitive Co-Authored-By: EvoX * fix(@vben-core/shadcn-ui): normalize progress indicator fill with custom max Co-Authored-By: EvoX * fix(@vben-core/shadcn-ui): guard Progress against non-positive max values Co-Authored-By: EvoX * chore(@vben-core/shadcn-ui): dedupe progress max fallback tests Co-Authored-By: EvoX * test(@vben-core/shadcn-ui): assert NaN max fallback instead of zero Co-Authored-By: EvoX * fix(@vben-core/shadcn-ui): reject non-finite progress max values Co-Authored-By: EvoX * fix(@vben-core/shadcn-ui): narrow max to number in progress normalization Co-Authored-By: EvoX --------- Co-authored-by: EvoX --- .changeset/olive-foxes-progress-meter.md | 5 + .../@core/ui-kit/shadcn-ui/src/ui/index.ts | 1 + .../shadcn-ui/src/ui/progress/Progress.vue | 58 ++++++++++ .../ui/progress/__tests__/progress.test.ts | 105 ++++++++++++++++++ .../ui-kit/shadcn-ui/src/ui/progress/index.ts | 1 + 5 files changed, 170 insertions(+) create mode 100644 .changeset/olive-foxes-progress-meter.md create mode 100644 packages/@core/ui-kit/shadcn-ui/src/ui/progress/Progress.vue create mode 100644 packages/@core/ui-kit/shadcn-ui/src/ui/progress/__tests__/progress.test.ts create mode 100644 packages/@core/ui-kit/shadcn-ui/src/ui/progress/index.ts diff --git a/.changeset/olive-foxes-progress-meter.md b/.changeset/olive-foxes-progress-meter.md new file mode 100644 index 000000000..a5f8cbf61 --- /dev/null +++ b/.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 diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/index.ts b/packages/@core/ui-kit/shadcn-ui/src/ui/index.ts index 1be71f862..57582f0f4 100644 --- a/packages/@core/ui-kit/shadcn-ui/src/ui/index.ts +++ b/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'; diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/progress/Progress.vue b/packages/@core/ui-kit/shadcn-ui/src/ui/progress/Progress.vue new file mode 100644 index 000000000..963ae62fb --- /dev/null +++ b/packages/@core/ui-kit/shadcn-ui/src/ui/progress/Progress.vue @@ -0,0 +1,58 @@ + + + diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/progress/__tests__/progress.test.ts b/packages/@core/ui-kit/shadcn-ui/src/ui/progress/__tests__/progress.test.ts new file mode 100644 index 000000000..1d4fcae30 --- /dev/null +++ b/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%)'); + }); +}); diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/progress/index.ts b/packages/@core/ui-kit/shadcn-ui/src/ui/progress/index.ts new file mode 100644 index 000000000..63f2d58f6 --- /dev/null +++ b/packages/@core/ui-kit/shadcn-ui/src/ui/progress/index.ts @@ -0,0 +1 @@ +export { default as Progress } from './Progress.vue';