diff --git a/docs/components/content/examples/MeterGroupExampleSlots.vue b/docs/components/content/examples/MeterGroupExampleSlots.vue new file mode 100644 index 00000000..2ac75482 --- /dev/null +++ b/docs/components/content/examples/MeterGroupExampleSlots.vue @@ -0,0 +1,17 @@ + diff --git a/docs/components/content/examples/MeterSlotIndicatorExample.vue b/docs/components/content/examples/MeterSlotIndicatorExample.vue new file mode 100644 index 00000000..73f4fa69 --- /dev/null +++ b/docs/components/content/examples/MeterSlotIndicatorExample.vue @@ -0,0 +1,15 @@ + + + diff --git a/docs/components/content/examples/MeterSlotLabelExample.vue b/docs/components/content/examples/MeterSlotLabelExample.vue new file mode 100644 index 00000000..d7e51d0a --- /dev/null +++ b/docs/components/content/examples/MeterSlotLabelExample.vue @@ -0,0 +1,15 @@ + + + diff --git a/docs/content/2.elements/11.meter.md b/docs/content/2.elements/11.meter.md new file mode 100644 index 00000000..b9c3cc86 --- /dev/null +++ b/docs/content/2.elements/11.meter.md @@ -0,0 +1,180 @@ +--- +title: 'Meter' +description: Display a gauge meter that fills or depletes. +navigation: + badge: New +links: + - label: GitHub + icon: i-simple-icons-github + to: https://github.com/nuxt/ui/blob/dev/src/runtime/components/elements/Meter.vue +--- + +## Usage + +Use the `value` prop from `0` to `100` to set a value for the meter bar. + +::component-card +--- +props: + value: 25 +--- +:: + +::callout{icon="i-heroicons-light-bulb"} +Check out the [Range](/forms/range) component for inputs +:: + +### Min & Max + +By default, `min` is `0` and `max` is `100`. You can change either of these using their respective props, even for negative numbers. + +::component-card +--- +props: + value: -25 + min: -50 + max: 50 +--- +:: + +### Indicator + +You may show a percentage indicator on top of the meter using the `indicator` prop. + +::component-card +--- +props: + value: 35 + indicator: true +--- +:: + +### Label + +Add a label below the meter using the `label` prop. + +::component-card +--- +baseProps: + value: 86 +props: + label: Disk usage +--- +:: + +### Icon + +You may also add an icon to the start label using the `icon` prop. + +::component-card +--- +baseProps: + value: 86 + label: Disk usage +props: + icon: i-heroicons-server +excludedProps: + - icon +--- +:: + +### Size + +Change the size of the meter bar using the `size` prop. + +::component-card +--- +baseProps: + value: 75.4 +props: + size: 'md' + indicator: true + label: CPU Load +--- +:: + +### Style + +The `color` prop changes the visual style of the meter bar. The `color` can be any color from the `ui.colors` object. + +::component-card +--- +baseProps: + value: 80 + indicator: true + label: Memory usage +props: + color: 'primary' +--- +:: + +## Group + +To group multiple meters into a group, adding all values, use the `MeterGroup` component. + +- To change the overall minimum and maximum value, pass the `min` and `max` props respectively. +- To change size of all meters, use the `size` prop. +- To show an indicator for the overall amount, set the `indicator` prop or slot. +- To change the color of each meter, use the `color` prop. +- To show a label for each meter, use the `label` prop on each meter. +- To change the icon for each meter, use the `icon` prop. + +::component-card{slug="MeterGroup"} +--- +baseProps: + icon: i-heroicons-minus +props: + min: 0 + max: 128 + size: 'md' + indicator: true +code: | + + + + + +--- + +#default +:u-meter{:value="24" color="gray" label="System"} +:u-meter{:value="8" color="red" label="Apps"} +:u-meter{:value="12" color="yellow" label="Documents"} +:u-meter{:value="42" color="green" label="Multimedia"} +:: + +::callout{icon="i-heroicons-exclamation-triangle"} +When the Meters are grouped, their individual indicators and label slots are stripped away. +:: + +A Meter group can also be used with an [indicator slot](#indicator-1), and even individual meter icons. + +:component-example{component="meter-group-example-slots"} + +## Slots + +### `indicator` + +Use the `#indicator` slot to change the indicator shown at the top of the bar. It receives the current meter percent. + +:component-example{component="meter-slot-indicator-example"} + +### `label` + +The `label` slot can be used to change how the label below the meter bar is shown. It receives the current meter percent. + +:component-example{component="meter-slot-label-example"} + +## Props + +:component-props + +:u-divider{label="MeterGroup" type="dashed" class="my-12"} + +:component-props{slug="MeterGroup"} + +## Preset + +:component-preset + +:component-preset{slug="MeterGroup"} diff --git a/src/colors.ts b/src/colors.ts index 707f49ad..2e853d56 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -177,6 +177,17 @@ const safelistByComponent = { }, { pattern: new RegExp(`text-(${colorsAsRegex})-500`) }], + meter: (colorsAsRegex) => [{ + pattern: new RegExp(`bg-(${colorsAsRegex})-400`), + variants: ['dark'] + }, { + pattern: new RegExp(`bg-(${colorsAsRegex})-500`) + }, { + pattern: new RegExp(`text-(${colorsAsRegex})-400`), + variants: ['dark'] + }, { + pattern: new RegExp(`text-(${colorsAsRegex})-500`) + }], notification: (colorsAsRegex) => [{ pattern: new RegExp(`bg-(${colorsAsRegex})-400`), variants: ['dark'] @@ -193,7 +204,9 @@ const safelistByComponent = { const safelistComponentAliasesMap = { 'USelect': 'UInput', 'USelectMenu': 'UInput', - 'UTextarea': 'UInput' + 'UTextarea': 'UInput', + 'URadioGroup': 'URadio', + 'UMeterGroup': 'UMeter' } const colorsAsRegex = (colors: string[]): string => colors.join('|') diff --git a/src/runtime/components/elements/Meter.vue b/src/runtime/components/elements/Meter.vue new file mode 100644 index 00000000..89997f42 --- /dev/null +++ b/src/runtime/components/elements/Meter.vue @@ -0,0 +1,187 @@ + + + diff --git a/src/runtime/components/elements/MeterGroup.ts b/src/runtime/components/elements/MeterGroup.ts new file mode 100644 index 00000000..c6caff46 --- /dev/null +++ b/src/runtime/components/elements/MeterGroup.ts @@ -0,0 +1,226 @@ +import { h, cloneVNode, computed, toRef, defineComponent } from 'vue' +import type { ComputedRef, VNode, SlotsType, PropType } from 'vue' +import { twMerge, twJoin } from 'tailwind-merge' +import UIcon from './Icon.vue' +import Meter from './Meter.vue' +import { useUI } from '../../composables/useUI' +import { mergeConfig, getSlotsChildren } from '../../utils' +import type { Strategy, MeterSize } from '../../types' +// @ts-expect-error +import appConfig from '#build/app.config' +import { meter, meterGroup } from '#ui/ui.config' + +const meterConfig = mergeConfig(appConfig.ui.strategy, appConfig.ui.meter, meter) +const meterGroupConfig = mergeConfig(appConfig.ui.strategy, appConfig.ui.meterGroup, meterGroup) + +export default defineComponent({ + components: { + UIcon + }, + inheritAttrs: false, + slots: Object as SlotsType<{ + default?: typeof Meter[], + indicator?: { percent: number }, + }>, + props: { + min: { + type: Number, + default: 0 + }, + max: { + type: Number, + default: 100 + }, + size: { + type: String as PropType, + default: () => meterConfig.default.size, + validator (value: string) { + return Object.keys(meterConfig.meter.bar.size).includes(value) + } + }, + indicator: { + type: Boolean, + default: false + }, + icon: { + type: String, + default: 'i-heroicons-minus' + }, + class: { + type: [String, Object, Array] as PropType, + default: undefined + }, + ui: { + type: Object as PropType>, + default: undefined + } + }, + setup (props, { slots }) { + const { ui: meterGroupUi, attrs } = useUI('meterGroup', toRef(props, 'ui'), meterGroupConfig) + const { ui: meterUi } = useUI('meter', undefined, meterConfig) + + // If there is no children, throw an expressive error. + if (!slots.default) { + throw new Error('Meter Group has no Meter children.') + } + + // Normalize the min and max numbers, if these are inversed. + const normalizedMin = computed(() => props.min > props.max ? props.max : props.min) + const normalizedMax = computed(() => props.max < props.min ? props.min : props.max) + + const children = computed(() => getSlotsChildren(slots)) + + const rounded = computed(() => { + const roundedMap = { + 'rounded-none': { left: 'rounded-s-none', right: 'rounded-e-none' }, + 'rounded-sm': { left: 'rounded-s-sm', right: 'rounded-e-sm' }, + rounded: { left: 'rounded-s', right: 'rounded-e' }, + 'rounded-md': { left: 'rounded-s-md', right: 'rounded-e-md' }, + 'rounded-lg': { left: 'rounded-s-lg', right: 'rounded-e-lg' }, + 'rounded-xl': { left: 'rounded-s-xl', right: 'rounded-e-xl' }, + 'rounded-2xl': { left: 'rounded-s-2xl', right: 'rounded-e-2xl' }, + 'rounded-3xl': { left: 'rounded-s-3xl', right: 'rounded-e-3xl' }, + 'rounded-full': { left: 'rounded-s-full', right: 'rounded-e-full' } + } + + return roundedMap[meterGroupUi.value.rounded] + }) + + function clampPercent (value: number, min: number, max: number): number { + if (min == max) { + return value < min ? 0 : 100 + } + + if (min > max) { + max = [min, min = max][0] + } + + const percent = (value - min) / (max - min) * 100 + + return Math.max(0, Math.min(100, percent)) + } + + // We have to store the labels outside to preserve reactivity later. + const labels = computed(() => { + return children.value.map(node => node.props.label) + }) + + const percents = computed(() => { + return children.value.map(node => clampPercent(node.props.value, props.min, props.max)) + }) + + const percent = computed(() => { + return Math.max(0, Math.max(percents.value.reduce((prev, percent) => prev + percent, 0))) + }) + + const clones: ComputedRef = computed(() => children.value.map((node, index) => { + const vProps: any = {} + + vProps.style = { width: `${percents.value[index]}%` } + + // Normalize the props to be the same on all groups + vProps.size = props.size + vProps.min = normalizedMin.value + vProps.max = normalizedMax.value + + // Adjust the style of all meters, so they appear in a row. + vProps.ui = node.props?.ui || {} + vProps.ui.wrapper = node.props?.ui?.wrapper || '' + vProps.ui.wrapper += [ + node.props?.ui?.wrapper, + props.ui?.meter?.background || meterGroupUi.value.background, + meterGroupUi.value.transition + ].filter(Boolean).join(' ') + + // Override the background to make the bar appear "full" + vProps.ui.meter = node.props?.ui?.meter || {} + vProps.ui.meter.background = `bg-${node.props.color}-500 dark:bg-${node.props.color}-400` + vProps.ui.meter.rounded = 'rounded-none' + vProps.ui.meter.bar = node.props?.ui?.meter?.bar || {} + + if (index === 0) { + vProps.ui.meter.rounded = `${rounded.value.left} rounded-e-none` + } + + if (index === children.value.length - 1) { + vProps.ui.meter.rounded = `${rounded.value.right} rounded-s-none` + } + + // Move the labels out of the node so these can be checked later + labels.value[index] = node.props.label + + const clone = cloneVNode(node, vProps) + + // @ts-expect-error + delete(clone.children?.label) + delete(clone.props.indicator) + delete(clone.props.label) + + return clone + })) + + const baseClass = computed(() => { + return twMerge(meterGroupUi.value.base, props.class) + }) + + const wrapperClass = computed(() => { + return twMerge(twJoin( + meterGroupUi.value.wrapper, + meterGroupUi.value.background, + meterGroupUi.value.rounded, + meterGroupUi.value.shadow, + meterUi.value.meter.size[props.size] + ), props.class) + }) + + const indicatorContainerClass = computed(() => { + return twJoin( + meterUi.value.indicator.container + ) + }) + + const indicatorClass = computed(() => { + return twJoin( + meterUi.value.indicator.text, + meterUi.value.indicator.size[props.size] + ) + }) + + const vNodeChildren = computed(() => { + const vNodeSlots = [ + undefined, + h('div', { class: wrapperClass.value }, clones.value), + undefined + ] + + if (props.indicator) { + vNodeSlots[0] = h('div', { class: indicatorContainerClass.value }, [ + h('div', { class: indicatorClass.value, style: { width: `${percent.value}%` } }, Math.round(percent.value) + '%') + ]) + } else if (slots.indicator) { + // @ts-expect-error + vNodeSlots[0] = slots.indicator({ percent: percent.value }) + } + + vNodeSlots[2] = h('ol', { class: 'list-disc list-inside' }, labels.value.map((label, key) => { + const labelClass = computed(() => { + return twJoin( + meterUi.value.label.base, + meterUi.value.label.text, + meterUi.value.color[clones.value[key]?.props.color] ?? meterUi.value.label.color.replaceAll('{color}', clones.value[key]?.props.color ?? meterUi.value.default.color), + meterUi.value.label.size[props.size] + ) + }) + + return h('li', { class: labelClass.value }, [ + h(UIcon, { name: clones.value[key]?.props.icon ?? props.icon }), + `${label} (${ Math.round(percents.value[key]) }%)` + ]) + })) + + return vNodeSlots + }) + + return () => h('div', { class: baseClass.value, ...attrs }, vNodeChildren.value) + } +}) diff --git a/src/runtime/types/index.d.ts b/src/runtime/types/index.d.ts index 7d2678bd..1b9c1ed3 100644 --- a/src/runtime/types/index.d.ts +++ b/src/runtime/types/index.d.ts @@ -7,6 +7,7 @@ export * from './command-palette' export * from './dropdown' export * from './form' export * from './link' +export * from './meter' export * from './notification' export * from './popper' export * from './progress' diff --git a/src/runtime/types/meter.d.ts b/src/runtime/types/meter.d.ts new file mode 100644 index 00000000..6ac121a6 --- /dev/null +++ b/src/runtime/types/meter.d.ts @@ -0,0 +1,5 @@ +import { meter } from '../ui.config' +import colors from '#ui-colors' + +export type MeterSize = keyof typeof meter.meter.size +export type MeterColor = keyof typeof meter.color | typeof colors[number] diff --git a/src/runtime/ui.config.ts b/src/runtime/ui.config.ts index 87b582da..879efec5 100644 --- a/src/runtime/ui.config.ts +++ b/src/runtime/ui.config.ts @@ -444,7 +444,7 @@ export const progress = { base: 'transition-all opacity-0 truncate row-start-1 col-start-1', align: 'text-end', active: 'opacity-100', - first: 'text-gray-500' + first: 'text-gray-500 dark:text-gray-400' }, animation: { carousel: 'bar-animation-carousel', @@ -459,6 +459,95 @@ export const progress = { } } +export const meter = { + wrapper: 'w-full flex flex-col gap-2', + indicator: { + container: 'min-w-fit transition-all', + text: 'text-gray-400 dark:text-gray-500 text-end', + size: { + '2xs': 'text-xs', + xs: 'text-xs', + sm: 'text-sm', + md: 'text-sm', + lg: 'text-sm', + xl: 'text-base', + '2xl': 'text-base' + } + }, + meter: { + base: 'appearance-none block w-full bg-none overflow-y-hidden', + background: 'bg-gray-200 dark:bg-gray-700', + color: 'text-{color}-500 dark:text-{color}-400', + ring: '', + rounded: 'rounded-full', + shadow: '', + size: { + '2xs': 'h-px', + xs: 'h-0.5', + sm: 'h-1', + md: 'h-2', + lg: 'h-3', + xl: 'h-4', + '2xl': 'h-5' + }, + appearance: { + inner: '[&::-webkit-meter-inner-element]:block [&::-webkit-meter-inner-element]:relative [&::-webkit-meter-inner-element]:border-none [&::-webkit-meter-inner-element]:bg-none [&::-webkit-meter-inner-element]:bg-transparent', + meter: '[&::-webkit-meter-bar]:border-none [&::-webkit-meter-bar]:bg-none [&::-webkit-meter-bar]:bg-transparent', + bar: '[&::-webkit-meter-optimum-value]:border-none [&::-webkit-meter-optimum-value]:bg-none [&::-webkit-meter-optimum-value]:bg-current', + value: '[&::-moz-meter-bar]:border-none [&::-moz-meter-bar]:bg-none [&::-moz-meter-bar]:bg-current' + }, + bar: { + transition: '[&::-webkit-meter-optimum-value]:transition-all [&::-moz-meter-bar]:transition-all', + ring: '', + rounded: '[&::-webkit-meter-optimum-value]:rounded-full [&::-moz-meter-bar]:rounded-full', + size: { + '2xs': '[&::-webkit-meter-optimum-value]:h-px [&::-moz-meter-bar]:h-px', + xs: '[&::-webkit-meter-optimum-value]:h-0.5 [&::-moz-meter-bar]:h-0.5', + sm: '[&::-webkit-meter-optimum-value]:h-1 [&::-moz-meter-bar]:h-1', + md: '[&::-webkit-meter-optimum-value]:h-2 [&::-moz-meter-bar]:h-2', + lg: '[&::-webkit-meter-optimum-value]:h-3 [&::-moz-meter-bar]:h-3', + xl: '[&::-webkit-meter-optimum-value]:h-4 [&::-moz-meter-bar]:h-4', + '2xl': '[&::-webkit-meter-optimum-value]:h-5 [&::-moz-meter-bar]:h-5' + } + } + }, + label: { + base: 'flex gap-2 items-center', + text: 'truncate', + color: 'text-{color}-500 dark:text-{color}-400', + size: { + '2xs': 'text-xs', + xs: 'text-xs', + sm: 'text-sm', + md: 'text-sm', + lg: 'text-sm', + xl: 'text-base', + '2xl': 'text-base' + } + }, + color: { + white: 'text-white dark:text-black', + black: 'text-black dark:text-white', + gray: 'text-gray-500 dark:text-gray-400' + }, + default: { + size: 'md', + color: 'primary' + } +} + +export const meterGroup = { + base: 'flex flex-col gap-2 w-full', + wrapper: 'flex flex-row flex-nowrap flex-shrink overflow-hidden', + background: 'bg-gray-200 dark:bg-gray-700', + transition: 'transition-all', + rounded: 'rounded-full', + shadow: '', + default: { + size: 'md' + } +} + // Forms export const input = {