feat(primitives): media-editor components, category reorg, perf + type cleanup
Reorganize components into category folders (forms/canvas/overlays/etc.); add the media-editor headless family (timeline, curve-editor, waveform, crop, color picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag state, gesture-leak teardown, shallowRef color state, rect caching) and replace source `any` with proper types.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The visual fill of the progress bar, rendered inside `ProgressRoot`. It reads
|
||||
* the value, max, and state from context and exposes them via `data-state`,
|
||||
* `data-value`, and `data-max` (plus matching slot props) so you can size and
|
||||
* style the fill — e.g. translating it by the completion percentage.
|
||||
*/
|
||||
export interface ProgressIndicatorProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useProgressContext } from './context';
|
||||
|
||||
const { as = 'div' } = defineProps<ProgressIndicatorProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const ctx = useProgressContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:data-state="ctx.state.value"
|
||||
:data-value="ctx.value.value ?? undefined"
|
||||
:data-max="ctx.max.value"
|
||||
>
|
||||
<slot
|
||||
:value="ctx.value.value"
|
||||
:max="ctx.max.value"
|
||||
:state="ctx.state.value"
|
||||
:progress="ctx.progress.value"
|
||||
:percentage="ctx.percentage.value"
|
||||
/>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
import type { ProgressState } from './context';
|
||||
|
||||
/**
|
||||
* A bar that shows the completion progress of a task, typically a horizontal
|
||||
* fill that grows from empty to full. Use it for file uploads, multi-step form
|
||||
* progress, loading indicators, or any operation whose progress you can measure
|
||||
* (or, with a `null` value, signal as indeterminate).
|
||||
*
|
||||
* The root renders the accessible `progressbar` (wiring up `aria-valuemin`,
|
||||
* `aria-valuemax`, `aria-valuenow`, `aria-valuetext`, and an `aria-label`
|
||||
* accessible name) and derives the current `state` — `indeterminate`,
|
||||
* `loading`, or `complete` — which it provides via context and exposes on the
|
||||
* `data-state` attribute. Pair it with `ProgressIndicator` for the visual fill.
|
||||
*
|
||||
* Both `modelValue` and `max` are two-way (`v-model` / `v-model:max`): bad
|
||||
* inputs (`NaN`, negatives, out-of-range, `max <= 0`) are validated, clamped,
|
||||
* and reported in development, so the rendered ARIA is always valid.
|
||||
*/
|
||||
export interface ProgressRootProps extends PrimitiveProps {
|
||||
/** Current value. `null` denotes an indeterminate progress bar. Two-way via `v-model`. */
|
||||
modelValue?: number | null;
|
||||
/** Maximum value. Two-way via `v-model:max`. @default 100 */
|
||||
max?: number;
|
||||
/**
|
||||
* Builds the `aria-valuetext` describing the current value in a human-readable
|
||||
* form. Receives the resolved value (`null` when indeterminate) and max.
|
||||
* @default `(v, m) => v == null ? undefined : `${Math.round((v / m) * 100)}%``
|
||||
*/
|
||||
getValueLabel?: (value: number | null, max: number) => string | undefined;
|
||||
/**
|
||||
* Accessible name for the progressbar, rendered as `aria-label`. Accepts a
|
||||
* static string or a function of the resolved value and max. Provide this (or
|
||||
* an external `aria-labelledby`) so screen readers announce a meaningful name.
|
||||
*/
|
||||
accessibleLabel?: string | ((value: number | null, max: number) => string | undefined);
|
||||
}
|
||||
|
||||
export interface ProgressRootEmits {
|
||||
/** Emitted when the value changes (after validation/clamping). */
|
||||
'update:modelValue': [value: number | null];
|
||||
/** Emitted when the max changes (after validation). */
|
||||
'update:max': [value: number];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { computed, ref } from 'vue';
|
||||
import { provideProgressContext } from './context';
|
||||
import { isFiniteNumber, resolveMax, resolveValue } from './utils';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
const {
|
||||
max: maxProp,
|
||||
getValueLabel = (v: number | null, m: number) => v === null ? undefined : `${Math.round((v / m) * 100)}%`,
|
||||
accessibleLabel,
|
||||
as = 'div',
|
||||
} = defineProps<ProgressRootProps>();
|
||||
|
||||
defineEmits<ProgressRootEmits>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const localValue = ref<number | null>(null);
|
||||
const localMax = ref<number>(resolveMax(maxProp));
|
||||
|
||||
const max = defineModel<number>('max', {
|
||||
get: external => resolveMax(external ?? localMax.value),
|
||||
set: (value) => {
|
||||
const next = resolveMax(value);
|
||||
localMax.value = next;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
|
||||
const value = defineModel<number | null>({
|
||||
get: external => resolveValue(external === undefined ? localValue.value : external, max.value),
|
||||
set: (raw) => {
|
||||
const next = resolveValue(raw, max.value);
|
||||
localValue.value = next;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
|
||||
const state = computed<ProgressState>(() => {
|
||||
const v = value.value;
|
||||
if (v === null) return 'indeterminate';
|
||||
if (v >= max.value) return 'complete';
|
||||
return 'loading';
|
||||
});
|
||||
|
||||
const progress = computed<number | null>(() => value.value === null ? null : value.value / max.value);
|
||||
const percentage = computed<number | null>(() => progress.value === null ? null : Math.round(progress.value * 100));
|
||||
|
||||
const valueText = computed(() => getValueLabel(value.value, max.value));
|
||||
const ariaLabel = computed(() => typeof accessibleLabel === 'function'
|
||||
? accessibleLabel(value.value, max.value)
|
||||
: accessibleLabel);
|
||||
|
||||
provideProgressContext({
|
||||
value,
|
||||
max,
|
||||
state,
|
||||
progress,
|
||||
percentage,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="progressbar"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="max"
|
||||
:aria-valuenow="isFiniteNumber(value) ? value : undefined"
|
||||
:aria-valuetext="valueText"
|
||||
:aria-label="ariaLabel"
|
||||
:data-state="state"
|
||||
:data-value="value ?? undefined"
|
||||
:data-max="max"
|
||||
>
|
||||
<slot
|
||||
:value="value"
|
||||
:max="max"
|
||||
:state="state"
|
||||
:progress="progress"
|
||||
:percentage="percentage"
|
||||
/>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,277 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { ProgressIndicator, ProgressRoot } from '../index';
|
||||
|
||||
function mountProgress(props: Record<string, unknown> = {}, slot?: (scope: Record<string, unknown>) => unknown) {
|
||||
return mount(defineComponent({
|
||||
setup: () => () => h(ProgressRoot, props, {
|
||||
default: (scope: Record<string, unknown>) =>
|
||||
slot ? slot(scope) : h(ProgressIndicator, { class: 'ind' }),
|
||||
}),
|
||||
}), { attachTo: document.body });
|
||||
}
|
||||
|
||||
describe('Progress', () => {
|
||||
it('has role="progressbar" with aria attributes', () => {
|
||||
const wrapper = mountProgress({ modelValue: 40 });
|
||||
expect(wrapper.attributes('role')).toBe('progressbar');
|
||||
expect(wrapper.attributes('aria-valuemin')).toBe('0');
|
||||
expect(wrapper.attributes('aria-valuemax')).toBe('100');
|
||||
expect(wrapper.attributes('aria-valuenow')).toBe('40');
|
||||
expect(wrapper.attributes('aria-valuetext')).toBe('40%');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is indeterminate when value is null', () => {
|
||||
const wrapper = mountProgress({ modelValue: null });
|
||||
expect(wrapper.attributes('data-state')).toBe('indeterminate');
|
||||
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('is complete when value reaches max', () => {
|
||||
const wrapper = mountProgress({ modelValue: 100 });
|
||||
expect(wrapper.attributes('data-state')).toBe('complete');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('custom max', () => {
|
||||
const wrapper = mountProgress({ modelValue: 5, max: 10 });
|
||||
expect(wrapper.attributes('aria-valuemax')).toBe('10');
|
||||
expect(wrapper.attributes('aria-valuetext')).toBe('50%');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('indicator receives matching data-state', () => {
|
||||
const wrapper = mountProgress({ modelValue: 70 });
|
||||
const ind = wrapper.find('.ind');
|
||||
expect(ind.attributes('data-state')).toBe('loading');
|
||||
expect(ind.attributes('data-value')).toBe('70');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('getValueLabel override', () => {
|
||||
const wrapper = mountProgress({ modelValue: 3, max: 10, getValueLabel: (v: number | null, m: number) => `${v} of ${m}` });
|
||||
expect(wrapper.attributes('aria-valuetext')).toBe('3 of 10');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
// ---- accessible name (aria-label) ----
|
||||
describe('accessible name', () => {
|
||||
it('renders no aria-label by default', () => {
|
||||
const wrapper = mountProgress({ modelValue: 40 });
|
||||
expect(wrapper.attributes('aria-label')).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('accepts a static string accessibleLabel as aria-label', () => {
|
||||
const wrapper = mountProgress({ modelValue: 40, accessibleLabel: 'Upload progress' });
|
||||
expect(wrapper.attributes('aria-label')).toBe('Upload progress');
|
||||
// value text is independent
|
||||
expect(wrapper.attributes('aria-valuetext')).toBe('40%');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('accepts a function accessibleLabel that receives value/max', () => {
|
||||
const wrapper = mountProgress({
|
||||
modelValue: 30,
|
||||
max: 60,
|
||||
accessibleLabel: (v: number | null, m: number) => `${v}/${m} done`,
|
||||
});
|
||||
expect(wrapper.attributes('aria-label')).toBe('30/60 done');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- input validation / clamping ----
|
||||
describe('input validation', () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('clamps a value above max', () => {
|
||||
const wrapper = mountProgress({ modelValue: 150, max: 100 });
|
||||
expect(wrapper.attributes('aria-valuenow')).toBe('100');
|
||||
expect(wrapper.attributes('data-state')).toBe('complete');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('clamps a negative value to 0', () => {
|
||||
const wrapper = mountProgress({ modelValue: -20 });
|
||||
expect(wrapper.attributes('aria-valuenow')).toBe('0');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('coerces NaN value to indeterminate and warns', () => {
|
||||
const wrapper = mountProgress({ modelValue: Number.NaN });
|
||||
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
|
||||
expect(wrapper.attributes('data-state')).toBe('indeterminate');
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('falls back to default max when max <= 0 and warns', () => {
|
||||
const wrapper = mountProgress({ modelValue: 50, max: 0 });
|
||||
expect(wrapper.attributes('aria-valuemax')).toBe('100');
|
||||
// value text uses the corrected max — no Infinity%
|
||||
expect(wrapper.attributes('aria-valuetext')).toBe('50%');
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('falls back to default max when max is NaN', () => {
|
||||
const wrapper = mountProgress({ modelValue: 50, max: Number.NaN });
|
||||
expect(wrapper.attributes('aria-valuemax')).toBe('100');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not render NaN aria-valuenow for non-finite input', () => {
|
||||
const wrapper = mountProgress({ modelValue: Number.POSITIVE_INFINITY });
|
||||
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- controlled two-way v-model ----
|
||||
describe('controlled v-model', () => {
|
||||
it('updates DOM when bound value changes', async () => {
|
||||
const value = ref<number | null>(20);
|
||||
const wrapper = mount(defineComponent({
|
||||
setup: () => () => h(ProgressRoot, {
|
||||
modelValue: value.value,
|
||||
'onUpdate:modelValue': (v: number | null) => { value.value = v; },
|
||||
}, { default: () => h(ProgressIndicator, { class: 'ind' }) }),
|
||||
}), { attachTo: document.body });
|
||||
expect(wrapper.attributes('aria-valuenow')).toBe('20');
|
||||
value.value = 80;
|
||||
await nextTick();
|
||||
expect(wrapper.attributes('aria-valuenow')).toBe('80');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('clamps an out-of-range bound value on read without mutating the parent', async () => {
|
||||
// One-way bind (no update listener): the bar displays a clamped value but
|
||||
// never writes back to a source the parent did not opt into mutating.
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const wrapper = mountProgress({ modelValue: 250, max: 100 });
|
||||
expect(wrapper.attributes('aria-valuenow')).toBe('100');
|
||||
expect(wrapper.attributes('data-state')).toBe('complete');
|
||||
errorSpy.mockRestore();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('declares the update:modelValue emit so v-model is a true two-way contract', async () => {
|
||||
const value = ref<number | null>(40);
|
||||
const wrapper = mount(defineComponent({
|
||||
setup: () => () => h(ProgressRoot, {
|
||||
modelValue: value.value,
|
||||
'onUpdate:modelValue': (v: number | null) => { value.value = v; },
|
||||
}, { default: () => h(ProgressIndicator) }),
|
||||
}), { attachTo: document.body });
|
||||
expect(wrapper.attributes('aria-valuenow')).toBe('40');
|
||||
// parent remains the source of truth and drives the bar
|
||||
value.value = 90;
|
||||
await nextTick();
|
||||
expect(wrapper.attributes('aria-valuenow')).toBe('90');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- uncontrolled mode ----
|
||||
describe('uncontrolled mode', () => {
|
||||
it('starts indeterminate when no modelValue is provided', () => {
|
||||
const wrapper = mount(ProgressRoot, {
|
||||
attachTo: document.body,
|
||||
slots: { default: () => h(ProgressIndicator) },
|
||||
});
|
||||
expect(wrapper.attributes('data-state')).toBe('indeterminate');
|
||||
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- two-way max ----
|
||||
describe('two-way max', () => {
|
||||
it('reacts to bound max changes', async () => {
|
||||
const max = ref(100);
|
||||
const wrapper = mount(defineComponent({
|
||||
setup: () => () => h(ProgressRoot, {
|
||||
modelValue: 50,
|
||||
max: max.value,
|
||||
'onUpdate:max': (m: number) => { max.value = m; },
|
||||
}, { default: () => h(ProgressIndicator) }),
|
||||
}), { attachTo: document.body });
|
||||
expect(wrapper.attributes('aria-valuetext')).toBe('50%');
|
||||
max.value = 200;
|
||||
await nextTick();
|
||||
expect(wrapper.attributes('aria-valuemax')).toBe('200');
|
||||
expect(wrapper.attributes('aria-valuetext')).toBe('25%');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- slot scope ----
|
||||
describe('slot scope', () => {
|
||||
it('exposes progress and percentage on the root slot', () => {
|
||||
const seen: Record<string, unknown> = {};
|
||||
const wrapper = mountProgress({ modelValue: 25, max: 50 }, (scope) => {
|
||||
Object.assign(seen, scope);
|
||||
return h('span');
|
||||
});
|
||||
expect(seen.value).toBe(25);
|
||||
expect(seen.max).toBe(50);
|
||||
expect(seen.state).toBe('loading');
|
||||
expect(seen.progress).toBe(0.5);
|
||||
expect(seen.percentage).toBe(50);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes progress/percentage as null when indeterminate', () => {
|
||||
const seen: Record<string, unknown> = {};
|
||||
const wrapper = mountProgress({ modelValue: null }, (scope) => {
|
||||
Object.assign(seen, scope);
|
||||
return h('span');
|
||||
});
|
||||
expect(seen.progress).toBeNull();
|
||||
expect(seen.percentage).toBeNull();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes progress/percentage on the indicator slot', () => {
|
||||
const indSeen: Record<string, unknown> = {};
|
||||
const wrapper = mount(defineComponent({
|
||||
setup: () => () => h(ProgressRoot, { modelValue: 30, max: 60 }, {
|
||||
default: () => h(ProgressIndicator, null, {
|
||||
default: (scope: Record<string, unknown>) => {
|
||||
Object.assign(indSeen, scope);
|
||||
return h('span');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}), { attachTo: document.body });
|
||||
expect(indSeen.progress).toBe(0.5);
|
||||
expect(indSeen.percentage).toBe(50);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- overshoot complete (robonen's preserved strength) ----
|
||||
it('keeps complete state when value overshoots after clamp', () => {
|
||||
const wrapper = mountProgress({ modelValue: 105, max: 100 });
|
||||
// clamped to 100 -> still complete
|
||||
expect(wrapper.attributes('data-state')).toBe('complete');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders data-value/data-max on the root', () => {
|
||||
const wrapper = mountProgress({ modelValue: 42, max: 84 });
|
||||
expect(wrapper.attributes('data-value')).toBe('42');
|
||||
expect(wrapper.attributes('data-max')).toBe('84');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
export type ProgressState = 'indeterminate' | 'loading' | 'complete';
|
||||
|
||||
export interface ProgressContext {
|
||||
/** Resolved (validated/clamped) value; `null` when indeterminate. */
|
||||
value: Ref<number | null>;
|
||||
/** Resolved (validated) maximum. */
|
||||
max: Ref<number>;
|
||||
/** Derived progress state. */
|
||||
state: Ref<ProgressState>;
|
||||
/** Completion ratio in `[0, 1]`, or `null` when indeterminate. */
|
||||
progress: ComputedRef<number | null>;
|
||||
/** Completion percentage in `[0, 100]`, or `null` when indeterminate. */
|
||||
percentage: ComputedRef<number | null>;
|
||||
}
|
||||
|
||||
const ctx = useContextFactory<ProgressContext>('ProgressContext');
|
||||
|
||||
export const provideProgressContext = ctx.provide;
|
||||
export const useProgressContext = ctx.inject;
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
import { ProgressIndicator, ProgressRoot } from '@robonen/primitives';
|
||||
|
||||
const value = ref(0);
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
value.value = 0;
|
||||
timer = setInterval(() => {
|
||||
value.value = Math.min(100, value.value + Math.round(8 + Math.random() * 14));
|
||||
if (value.value >= 100)
|
||||
stop();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(stop);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-card w-full max-w-sm space-y-6 p-5 text-fg">
|
||||
<!-- Determinate: a simulated upload -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-baseline justify-between text-sm">
|
||||
<span class="font-medium">Uploading build.zip</span>
|
||||
<span class="font-mono text-fg-muted">{{ value }}%</span>
|
||||
</div>
|
||||
|
||||
<ProgressRoot
|
||||
v-slot="{ state }"
|
||||
:model-value="value"
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-bg-inset"
|
||||
>
|
||||
<ProgressIndicator
|
||||
class="h-full rounded-full transition-transform duration-500 ease-out"
|
||||
:class="state === 'complete'
|
||||
? 'bg-emerald-500 dark:bg-emerald-400'
|
||||
: 'bg-accent'"
|
||||
:style="{ transform: `translateX(-${100 - value}%)` }"
|
||||
/>
|
||||
</ProgressRoot>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border bg-bg px-3 py-1.5 text-sm text-fg transition hover:bg-bg-inset active:scale-95 cursor-pointer"
|
||||
@click="start"
|
||||
>
|
||||
{{ value === 0 ? 'Start upload' : 'Restart' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Indeterminate: value is null -->
|
||||
<div class="space-y-2">
|
||||
<span class="text-sm font-medium">Syncing changes…</span>
|
||||
|
||||
<ProgressRoot
|
||||
:model-value="null"
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-bg-inset"
|
||||
>
|
||||
<ProgressIndicator class="h-full w-2/5 animate-pulse rounded-full bg-accent" />
|
||||
</ProgressRoot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as ProgressRoot } from './ProgressRoot.vue';
|
||||
export { default as ProgressIndicator } from './ProgressIndicator.vue';
|
||||
export type { ProgressRootProps, ProgressRootEmits } from './ProgressRoot.vue';
|
||||
export type { ProgressIndicatorProps } from './ProgressIndicator.vue';
|
||||
export { provideProgressContext, useProgressContext } from './context';
|
||||
export type { ProgressState, ProgressContext } from './context';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { isNull, isNumber, isUndefined } from '@robonen/stdlib';
|
||||
|
||||
/** Fallback maximum used when `max` is omitted or invalid. */
|
||||
export const DEFAULT_MAX = 100;
|
||||
|
||||
/** `null`/`undefined` mark an indeterminate progress bar. */
|
||||
export function isNullish(value: unknown): value is null | undefined {
|
||||
return isNull(value) || isUndefined(value);
|
||||
}
|
||||
|
||||
/** A real, finite numeric value (filters `NaN`/`Infinity`). */
|
||||
export function isFiniteNumber(value: unknown): value is number {
|
||||
return isNumber(value) && Number.isFinite(value);
|
||||
}
|
||||
|
||||
const VALUE_MESSAGE = (value: unknown, max: number): string =>
|
||||
`Invalid \`modelValue\` of \`${String(value)}\` supplied to ProgressRoot. `
|
||||
+ `The value must be a finite number between 0 and \`max\` (${max}), `
|
||||
+ 'or `null`/`undefined` for an indeterminate bar. Defaulting to `null`.';
|
||||
|
||||
const MAX_MESSAGE = (max: unknown): string =>
|
||||
`Invalid \`max\` of \`${String(max)}\` supplied to ProgressRoot. `
|
||||
+ `Only finite numbers greater than 0 are valid. Defaulting to \`${DEFAULT_MAX}\`.`;
|
||||
|
||||
/**
|
||||
* Resolve a usable `max`: a finite number greater than `0`, otherwise the
|
||||
* default. Warns once in dev when an invalid value is supplied. Compiled out of
|
||||
* production via the `__DEV__` global.
|
||||
*/
|
||||
export function resolveMax(max: number | undefined): number {
|
||||
if (isUndefined(max)) return DEFAULT_MAX;
|
||||
if (isFiniteNumber(max) && max > 0) return max;
|
||||
|
||||
if (__DEV__) console.error(MAX_MESSAGE(max));
|
||||
return DEFAULT_MAX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a usable value against an already-resolved `max`: pass through
|
||||
* nullish (indeterminate) values, clamp finite numbers into `[0, max]`, and
|
||||
* coerce anything else (`NaN`, non-numbers) to `null`. Warns once in dev when
|
||||
* coercing an unusable value. Compiled out of production via `__DEV__`.
|
||||
*/
|
||||
export function resolveValue(value: number | null | undefined, max: number): number | null {
|
||||
if (isNullish(value)) return null;
|
||||
if (isFiniteNumber(value)) {
|
||||
if (value < 0) return 0;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
if (__DEV__) console.error(VALUE_MESSAGE(value, max));
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user