feat(Progress): new component (#75)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Sandro Circi
2024-05-15 15:26:07 +02:00
committed by GitHub
parent 9037a1d94c
commit 138cb2d12d
9 changed files with 820 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { ProgressRootProps, ProgressRootEmits } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/progress'
const appConfig = _appConfig as AppConfig & { ui: { progress: Partial<typeof theme> } }
const progress = tv({ extend: tv(theme), ...(appConfig.ui?.progress || {}) })
type ProgressVariants = VariantProps<typeof progress>
export interface ProgressProps extends Omit<ProgressRootProps, 'asChild' | 'max'> {
max?: number | Array<any>
status?: boolean
inverted?: boolean
size?: ProgressVariants['size']
color?: ProgressVariants['color']
orientation?: ProgressVariants['orientation']
animation?: ProgressVariants['animation']
class?: any
ui?: Partial<typeof progress.slots>
}
export interface ProgressEmits extends ProgressRootEmits {}
export type ProgressSlots = {
status(props: { percent?: number }): any
} & {
[key: string]: (props: { step: number }) => any
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { ProgressIndicator, ProgressRoot, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
const props = withDefaults(defineProps<ProgressProps>(), {
inverted: false,
modelValue: null,
orientation: 'horizontal'
})
const emits = defineEmits<ProgressEmits>()
defineSlots<ProgressSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'getValueLabel', 'modelValue'), emits)
const isIndeterminate = computed(() => rootProps.value.modelValue === null)
const hasSteps = computed(() => Array.isArray(props.max))
const realMax = computed(() => {
if (isIndeterminate.value || !props.max) {
return undefined
}
if (Array.isArray(props.max)) {
return props.max.length - 1
}
return Number(props.max)
})
const percent = computed(() => {
if (isIndeterminate.value) {
return undefined
}
switch (true) {
case rootProps.value.modelValue! < 0: return 0
case rootProps.value.modelValue! > (realMax.value ?? 100): return 100
default: return Math.round((rootProps.value.modelValue! / (realMax.value ?? 100)) * 100)
}
})
const indicatorStyle = computed(() => {
if (!percent.value) {
return
}
return {
transform: `translate${props.orientation === 'vertical' ? 'Y' : 'X'}(${props.inverted ? '' : '-'}${100 - percent.value}%)`
}
})
const statusStyle = computed(() => {
return {
[props.orientation === 'vertical' ? 'height' : 'width']: percent.value ? `${percent.value}%` : 'fit-content'
}
})
function isActive(index: number) {
return index === Number(props.modelValue)
}
function isFirst(index: number) {
return index === 0
}
function isLast(index: number) {
return index === realMax.value
}
function stepVariant(index: number | string) {
index = Number(index)
if (isActive(index) && !isFirst(index)) {
return 'active'
}
if (isFirst(index) && isActive(index)) {
return 'first'
}
if (isLast(index) && isActive(index)) {
return 'last'
}
return 'other'
}
const ui = computed(() => tv({ extend: progress, slots: props.ui })({
animation: props.animation,
size: props.size,
color: props.color,
orientation: props.orientation,
inverted: props.inverted
}))
</script>
<template>
<div :class="ui.root({ class: props.class })">
<div v-if="!isIndeterminate && (status || $slots.status)" :class="ui.status()" :style="statusStyle">
<slot name="status" :percent="percent">
{{ percent }}%
</slot>
</div>
<ProgressRoot v-bind="rootProps" :max="realMax" :class="ui.base()" style="transform: translateZ(0)">
<ProgressIndicator :class="ui.indicator()" :style="indicatorStyle" />
</ProgressRoot>
<div v-if="hasSteps" :class="ui.steps()">
<div v-for="(step, index) in max" :key="index" :class="ui.step({ step: stepVariant(index) })">
<slot :name="`step-${index}`" :step="step">
{{ step }}
</slot>
</div>
</div>
</div>
</template>

View File

@@ -26,6 +26,7 @@ export * from '../components/Modal.vue'
export * from '../components/NavigationMenu.vue'
export * from '../components/Pagination.vue'
export * from '../components/Popover.vue'
export * from '../components/Progress.vue'
export * from '../components/RadioGroup.vue'
export * from '../components/Select.vue'
export * from '../components/SelectMenu.vue'

View File

@@ -152,6 +152,106 @@ export function addTemplates(options: ModuleOptions, nuxt: Nuxt) {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(-200px); }
}
@keyframes carousel {
0%,
100% {
width: 50%
}
0% {
transform: translateX(-100%)
}
100% {
transform: translateX(200%)
}
}
@keyframes carousel-vertical {
0%,
100% {
height: 50%
}
0% {
transform: translateY(-100%)
}
100% {
transform: translateY(200%)
}
}
@keyframes carousel-inverse {
0%,
100% {
width: 50%
}
0% {
transform: translateX(200%)
}
100% {
transform: translateX(-100%)
}
}
@keyframes carousel-inverse-vertical {
0%
100% {
height: 50%
}
0% {
transform: translateY(200%)
}
100% {
transform: translateY(-100%)
}
}
@keyframes swing {
0%,
100% {
width: 50%
}
0%,
100% {
transform: translateX(-25%)
}
50% {
transform: translateX(125%)
}
}
@keyframes swing-vertical {
0%,
100% {
height: 50%
}
0%,
100% {
transform: translateY(-25%)
}
50% {
transform: translateY(125%)
}
}
@keyframes elastic {
/* Firefox doesn't do "margin: 0 auto", we have to play with margin-left */
0%,
100% {
width: 50%;
margin-left: 25%;
}
50% {
width: 90%;
margin-left: 5%;
}
}
@keyframes elastic-vertical {
0%,
100% {
height: 50%;
margin-top: 25%;
}
50% {
height: 90%;
margin-top: 5%;
}
}
}
@theme {

View File

@@ -25,6 +25,7 @@ export { default as modal } from './modal'
export { default as navigationMenu } from './navigation-menu'
export { default as pagination } from './pagination'
export { default as popover } from './popover'
export { default as progress } from './progress'
export { default as radioGroup } from './radio-group'
export { default as select } from './select'
export { default as selectMenu } from './select-menu'

204
src/theme/progress.ts Normal file
View File

@@ -0,0 +1,204 @@
export default (config: { colors: string[] }) => ({
slots: {
root: 'gap-2',
base: 'relative overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700',
indicator: 'rounded-full size-full transition-transform duration-200 ease-out',
status: 'flex justify-end text-gray-400 dark:text-gray-500 transition-transform',
steps: 'grid items-end',
step: 'transition-opacity truncate text-end row-start-1 col-start-1'
},
variants: {
animation: {
'carousel': '',
'carousel-inverse': '',
'swing': '',
'elastic': ''
},
color: Object.fromEntries(config.colors.map((color: string) => [color, {
indicator: `bg-${color}-500 dark:bg-${color}-400`,
steps: `text-${color}-500 dark:text-${color}-400`
}])),
size: {
'2xs': {
status: 'text-xs',
steps: 'text-xs'
},
'xs': {
status: 'text-xs',
steps: 'text-xs'
},
'sm': {
status: 'text-sm',
steps: 'text-sm'
},
'md': {
status: 'text-sm',
steps: 'text-sm'
},
'lg': {
status: 'text-sm',
steps: 'text-sm'
},
'xl': {
status: 'text-base',
steps: 'text-base'
},
'2xl': {
status: 'text-base',
steps: 'text-base'
}
},
step: {
active: {
step: 'opacity-100'
},
first: {
step: 'opacity-100 text-gray-500 dark:text-gray-400'
},
other: {
step: 'opacity-0'
}
},
orientation: {
horizontal: {
root: 'w-full flex flex-col',
base: 'w-full',
status: 'flex-row'
},
vertical: {
root: 'h-full flex flex-row-reverse',
base: 'h-full',
status: 'flex-col'
}
},
inverted: {
true: {
status: 'self-end'
}
}
},
compoundVariants: [{
inverted: true,
orientation: 'horizontal',
class: {
step: 'text-start',
status: 'flex-row-reverse'
}
}, {
inverted: true,
orientation: 'vertical',
class: {
steps: 'items-start',
status: 'flex-col-reverse'
}
}, {
orientation: 'horizontal',
size: '2xs',
class: 'h-px'
}, {
orientation: 'horizontal',
size: 'xs',
class: 'h-0.5'
}, {
orientation: 'horizontal',
size: 'sm',
class: 'h-1'
}, {
orientation: 'horizontal',
size: 'md',
class: 'h-2'
}, {
orientation: 'horizontal',
size: 'lg',
class: 'h-3'
}, {
orientation: 'horizontal',
size: 'xl',
class: 'h-4'
}, {
orientation: 'horizontal',
size: '2xl',
class: 'h-5'
}, {
orientation: 'vertical',
size: '2xs',
class: 'w-px'
}, {
orientation: 'vertical',
size: 'xs',
class: 'w-0.5'
}, {
orientation: 'vertical',
size: 'sm',
class: 'w-1'
}, {
orientation: 'vertical',
size: 'md',
class: 'w-2'
}, {
orientation: 'vertical',
size: 'lg',
class: 'w-3'
}, {
orientation: 'vertical',
size: 'xl',
class: 'w-4'
}, {
orientation: 'vertical',
size: '2xl',
class: 'w-5'
}, {
orientation: 'horizontal',
animation: 'carousel',
class: {
indicator: 'data-[state=indeterminate]:animate-[carousel_2s_ease-in-out_infinite]'
}
}, {
orientation: 'vertical',
animation: 'carousel',
class: {
indicator: 'data-[state=indeterminate]:animate-[carousel-vertical_2s_ease-in-out_infinite]'
}
}, {
orientation: 'horizontal',
animation: 'carousel-inverse',
class: {
indicator: 'data-[state=indeterminate]:animate-[carousel-inverse_2s_ease-in-out_infinite]'
}
}, {
orientation: 'vertical',
animation: 'carousel-inverse',
class: {
indicator: 'data-[state=indeterminate]:animate-[carousel-inverse-vertical_2s_ease-in-out_infinite]'
}
}, {
orientation: 'horizontal',
animation: 'swing',
class: {
indicator: 'data-[state=indeterminate]:animate-[swing_2s_ease-in-out_infinite]'
}
}, {
orientation: 'vertical',
animation: 'swing',
class: {
indicator: 'data-[state=indeterminate]:animate-[swing-vertical_2s_ease-in-out_infinite]'
}
}, {
orientation: 'horizontal',
animation: 'elastic',
class: {
indicator: 'data-[state=indeterminate]:animate-[elastic_2s_ease-in-out_infinite]'
}
}, {
orientation: 'vertical',
animation: 'elastic',
class: {
indicator: 'data-[state=indeterminate]:animate-[elastic-vertical_2s_ease-in-out_infinite]'
}
}],
defaultVariants: {
animation: 'carousel',
color: 'primary',
size: 'md'
}
})