From a920a2ddd846d9465a1b7acec2a9655823afe4ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=9C=D0=B0?= =?UTF-8?q?=D0=BB=D1=8E=D0=B3=D0=B8=D0=BD?= <d.malygin@iqdev.digital> Date: Fri, 17 Jan 2025 15:59:17 +0500 Subject: [PATCH] feat: component 'Knob' --- src/Playground.vue | 3 + src/common/helpers/common.ts | 3 +- src/common/interfaces/common.ts | 20 +++ src/common/interfaces/componentsProp.ts | 7 + src/common/interfaces/componentsProps.ts | 17 ++- src/stories/components/Knob/Knob.stories.ts | 129 ++++++++++------ src/stories/components/Knob/Knob.vue | 157 +++++++++++++++++--- src/stories/components/Knob/helpers.ts | 61 ++++++++ 8 files changed, 330 insertions(+), 67 deletions(-) create mode 100644 src/stories/components/Knob/helpers.ts diff --git a/src/Playground.vue b/src/Playground.vue index ba41d2a..bc22e0d 100644 --- a/src/Playground.vue +++ b/src/Playground.vue @@ -17,6 +17,7 @@ import Checkbox from '@stories/components/Checkbox/Checkbox.vue'; import Tag from '@stories/components/Tag/Tag.vue'; import Select from '@stories/components/Select/Select.vue'; import AtIcon from '@stories/icons/Mono/AtIcon.vue'; +import Knob from '@stories/components/Knob/Knob.vue'; const visibleDrawer = ref(false); const sliderOptions: ISliderOptions[] = [ @@ -191,10 +192,12 @@ const selectOptions = [ value: 'Second', }, ]; +const knob = ref(); </script> <template> <h2 class="title gradient-text">Playground</h2> + <Knob v-model:value="knob" /> <Select :options="selectOptions" theme="sky"> <template #icon-left-First> <AtIcon color="#3aa" size="20" /> diff --git a/src/common/helpers/common.ts b/src/common/helpers/common.ts index f3e9829..d224eb3 100644 --- a/src/common/helpers/common.ts +++ b/src/common/helpers/common.ts @@ -1,4 +1,4 @@ -import type { TDarkness, TThemeColor } from '@interfaces/common'; +import { EThemeColor, type TDarkness, type TThemeColor } from '@interfaces/common'; import { convert100ThemeToColor, convert200ThemeToColor, @@ -15,6 +15,7 @@ import { * Convert color of type TThemeColor to hex */ export const convertThemeToColor = (theme: TThemeColor, darkness: TDarkness | string = '500'): string => { + if (!(theme in EThemeColor)) return theme; if (darkness === '500') return convert500ThemeToColor(theme); if (darkness === '100') return convert100ThemeToColor(theme); if (darkness === '200') return convert200ThemeToColor(theme); diff --git a/src/common/interfaces/common.ts b/src/common/interfaces/common.ts index 7a0383a..cb8b472 100644 --- a/src/common/interfaces/common.ts +++ b/src/common/interfaces/common.ts @@ -21,6 +21,26 @@ export type TThemeColor = | 'red' | 'black'; +export enum EThemeColor { + 'white', + 'blue', + 'sky', + 'cyan', + 'teal', + 'lime', + 'green', + 'yellow', + 'amber', + 'orange', + 'pink', + 'fuchsia', + 'purple', + 'indigo', + 'rose', + 'red', + 'black', +} + export type TDarkness = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; export type TThemeColorNoWhite = Exclude<TThemeColor, 'white'>; diff --git a/src/common/interfaces/componentsProp.ts b/src/common/interfaces/componentsProp.ts index 8f00281..3b7b90a 100644 --- a/src/common/interfaces/componentsProp.ts +++ b/src/common/interfaces/componentsProp.ts @@ -55,6 +55,13 @@ export interface IMDItemProps { onClick?: () => void; } +export interface IKnobColorGap { + start: number; + end: number; + color: TThemeColor; + darknessColor?: TDarkness; +} + export interface ISelectOption { value: string; label?: string; diff --git a/src/common/interfaces/componentsProps.ts b/src/common/interfaces/componentsProps.ts index ef09ded..46309d8 100644 --- a/src/common/interfaces/componentsProps.ts +++ b/src/common/interfaces/componentsProps.ts @@ -11,6 +11,7 @@ import type { TThemeColorNoWhite, } from '@interfaces/common'; import type { + IKnobColorGap, IMDItemProps, ISBOption, ISelectGroup, @@ -69,16 +70,25 @@ export interface IMDProps { } export interface IKnobProps { - min?: string | number; - max?: string | number; - step?: string | number; + min?: number; + max?: number; + step?: number; size?: TSize; theme?: TThemeColor; + colorGaps?: IKnobColorGap[]; negativeTheme?: TThemeColor; + color?: TThemeColor; + background?: string; darknessTheme?: TDarkness; darknessNegativeTheme?: TDarkness; + darknessColor?: TDarkness; buttons?: boolean; showLabel?: boolean; + colorAsTheme?: boolean; + fontSize?: string; + textBold?: boolean; + textBefore?: string; + textAfter?: string; } export interface ISliderProps { @@ -175,6 +185,7 @@ export interface IButtonProps { textStyle?: TTextStyle; iconPos?: TPosition; width?: string | number; + padding?: string; iconOnly?: boolean; theme?: TThemeColor; textColor?: TThemeColor; diff --git a/src/stories/components/Knob/Knob.stories.ts b/src/stories/components/Knob/Knob.stories.ts index ea90618..eafa6c6 100644 --- a/src/stories/components/Knob/Knob.stories.ts +++ b/src/stories/components/Knob/Knob.stories.ts @@ -16,17 +16,26 @@ const meta: Meta = { argTypes: { buttons: { control: 'boolean' }, showLabel: { control: 'boolean' }, - min: { control: 'text' }, - max: { control: 'text' }, - step: { control: 'text' }, - size: { control: 'Knob', options: ['small', 'normal', 'large', 'huge'] }, - darknessTheme: { control: 'Knob', options: ['100', '200', '300', '400', '500', '600', '700', '800', '900'] }, + colorAsTheme: { control: 'boolean' }, + textBold: { control: 'boolean' }, + value: { control: 'number' }, + min: { control: 'number' }, + max: { control: 'number' }, + step: { control: 'number' }, + fontSize: { control: 'text' }, + textBefore: { control: 'text' }, + textAfter: { control: 'text' }, + colorGaps: { control: 'object' }, + size: { control: 'select', options: ['small', 'normal', 'large', 'huge'] }, + background: { control: 'color' }, + darknessTheme: { control: 'select', options: ['100', '200', '300', '400', '500', '600', '700', '800', '900'] }, darknessNegativeTheme: { - control: 'Knob', + control: 'select', options: ['100', '200', '300', '400', '500', '600', '700', '800', '900'], }, + darknessColor: { control: 'select', options: ['100', '200', '300', '400', '500', '600', '700', '800', '900'] }, theme: { - control: 'Knob', + control: 'select', options: [ 'white', 'blue', @@ -46,7 +55,27 @@ const meta: Meta = { ], }, negativeTheme: { - control: 'Knob', + control: 'select', + options: [ + 'white', + 'blue', + 'sky', + 'cyan', + 'teal', + 'green', + 'yellow', + 'orange', + 'pink', + 'fuchsia', + 'purple', + 'indigo', + 'rose', + 'red', + 'black', + ], + }, + color: { + control: 'select', options: [ 'white', 'blue', @@ -73,58 +102,66 @@ export default meta; type Story = StoryObj<typeof meta>; export const Simple: Story = { + args: {}, +}; + +export const Half: Story = { args: { - options: [ - { - value: 'First', - }, - { - value: 'Second', - }, - { - value: 'Third', - }, - ], + textBold: true, + colorAsTheme: true, + showLabel: true, + min: 0, + max: 5000, + theme: 'red', + negativeTheme: 'yellow', + value: 2500, + textAfter: '', + textBefore: '$', + step: 1, + fontSize: '1.3rem', }, }; export const Full: Story = { args: { - options: [ + value: 60, + min: 0, + max: 100, + size: 'huge', + + colorGaps: [ { - value: 'First', - iconLeft: 'At', - color: 'purple', - darknessColor: '800', - group: 'Group', + start: 0, + end: 20, + color: 'red', }, { - value: 'Second', - iconRightColor: 'red', - iconRight: 'Age18', - group: 'Group', + start: 20, + end: 40, + color: 'orange', + darknessColor: '400', }, { - iconLeft: 'Calendar', - value: 'Third', - iconRight: 'CheckMark', - group: 'Group 2', + start: 40, + end: 60, + color: 'yellow', + darknessColor: '400', }, { - value: 'Sssss', + start: 60, + end: 80, + color: 'green', + darknessColor: '600', }, ], - groups: [ - { name: 'Group', background: 'white', iconLeft: 'Archive' }, - { name: 'Group 2', background: 'red', iconLeft: 'Badge' }, - ], - placeholder: 'Knob a city', - size: 'normal', - width: '250px', - theme: 'sky', - background: 'sky', - darknessTheme: '700', - darknessBackground: '200', - openIconColor: 'sky', + + theme: 'blue', + darknessTheme: '500', + step: 2, + textBold: true, + colorAsTheme: true, + textBefore: '', + textAfter: '%', + buttons: true, }, }; diff --git a/src/stories/components/Knob/Knob.vue b/src/stories/components/Knob/Knob.vue index 7ed7a92..3e0a74b 100644 --- a/src/stories/components/Knob/Knob.vue +++ b/src/stories/components/Knob/Knob.vue @@ -1,20 +1,68 @@ <script setup lang="ts"> import type { IKnobProps } from '@interfaces/componentsProps'; -import { computed } from 'vue'; +import { computed, ref, type Ref } from 'vue'; import { convertThemeToColor } from '@helpers/common'; +import { + calcCenter, + calcStart, + calcNewValue, + calcThemeColor, + calcContainerSize, +} from '@stories/components/Knob/helpers'; +import Button from '@stories/components/Button/Button.vue'; const props = withDefaults(defineProps<IKnobProps>(), { + value: 0, min: 0, - max: 2, + max: 5, + step: 1, + size: 'normal', + buttons: false, + theme: 'sky', + darknessTheme: '500', negativeTheme: 'black', + darknessNegativeTheme: '500', + color: 'black', + darknessColor: '500', + background: 'white', showLabel: true, + colorAsTheme: false, + textBold: false, }); -const value = defineModel('value'); +const value = defineModel<number>('value', { + default: 0, +}) as Ref<number>; + +const isClickHold = ref<boolean>(false); const degreesTotal = computed(() => 360 - 90); const length = computed(() => props.max - props.min); -const textColor = computed(() => convertThemeToColor(props.theme, props.darknessTheme)); -const background = computed(() => { +const center = computed(() => calcCenter(document.querySelector('.container')!)); +const start = computed(() => calcStart(document.querySelector('.container')!)); +const containerSize = computed(() => calcContainerSize(props.size)); +const buttonSize = computed(() => { + const size = props.size; + return size === 'normal' || size === 'small' ? 'small' : size === 'large' ? 'large' : 'huge'; +}); +const textSize = computed(() => { + if (props.fontSize) return props.fontSize; + const size = props.size; + return size === 'normal' ? '1.7rem' : size === 'small' ? '1.3rem' : size === 'large' ? '2.5rem' : '3.5rem'; +}); +const buttonPadding = computed(() => { + const size = props.size; + return size === 'normal' + ? '0.3rem 0.5rem' + : size === 'small' + ? '0.2rem' + : size === 'large' + ? '0.5rem 0.75rem' + : '0.7rem 1rem'; +}); +const backgroundSize = computed(() => `${containerSize.value.slice(0, -2) * 0.71}px`); +const themeColor = computed(() => calcThemeColor(props.colorGaps, props.theme, props.darknessTheme, value.value)); +const textColor = computed(() => convertThemeToColor(props.color, props.darknessColor)); +const backgroundCircle = computed(() => { const color = convertThemeToColor( props.negativeTheme ?? (props.theme === 'white' ? 'black' : props.theme === 'black' ? 'white' : props.theme), (!props.negativeTheme && props.theme === 'black') || props.negativeTheme === 'white' @@ -24,19 +72,69 @@ const background = computed(() => { return `radial-gradient(circle at center, transparent 50%, ${color} 50%)`; }); const conicGradient = computed(() => { - return `conic-gradient(red ${(225 + (1 / length.value) * degreesTotal.value) % 360}deg, transparent 225deg)`; + const valueDeg = 225 + (value.value / length.value) * degreesTotal.value; + if (valueDeg >= 360) + return `conic-gradient(${themeColor.value} 0deg ${valueDeg % 360}deg, transparent ${valueDeg % 360}deg 225deg, ${themeColor.value} 225deg 360deg)`; + return `conic-gradient(transparent 0deg 225deg, ${themeColor.value} 225deg ${valueDeg}deg, transparent ${valueDeg}deg 360deg)`; }); + +const setNewValue = ($event) => { + value.value = calcNewValue( + $event, + center.value, + start.value, + degreesTotal.value, + length.value, + props.step, + value.value, + ); +}; +const onPointerDown = ($event) => { + isClickHold.value = true; + setNewValue($event); +}; </script> <template> - {{ (225 + (1 / length) * degreesTotal) % 360 }} - {{ length }} - {{ conicGradient }} - <section class="container"> - <div class="circle" :style="`width: 100px; height: 100px`"> - <div class="circle selected" :style="`width: 100px; height: 100px`"></div> - - <span v-if="showLabel" class="count">{{ value }}</span> + <section + @pointerdown.prevent="!buttons && onPointerDown($event)" + @pointermove="isClickHold ? setNewValue($event) : ''" + @pointerup="isClickHold = false" + class="container containerSize" + > + <div class="background"></div> + <span + v-if="showLabel" + class="count" + :style="`color: ${colorAsTheme ? themeColor : textColor}; + font-weight: ${textBold ? 'bold' : 'medium'}; + font-size: ${textSize}`" + >{{ textBefore ?? '' }}{{ value }}{{ textAfter ?? '' }}</span + > + <div class="circle containerSize"> + <div class="circle containerSize selected"></div> + </div> + <div v-if="buttons" class="buttons" :style="`gap: ${textSize.slice(0, -3) * 3}px`"> + <Button + @click="value++" + :theme="negativeTheme" + textColor="white" + :size="buttonSize" + label="+" + textStyle="bold" + :padding="buttonPadding" + :width="`${textSize.slice(0, -3) * 0.78}rem`" + ></Button> + <Button + @click="value--" + :theme="negativeTheme" + textColor="white" + :size="buttonSize" + label="-" + textStyle="bold" + :padding="buttonPadding" + :width="`${textSize.slice(0, -3) * 0.78}rem`" + ></Button> </div> </section> </template> @@ -45,25 +143,50 @@ const conicGradient = computed(() => { .container { position: relative; } +.containerSize { + width: v-bind(containerSize); + height: v-bind(containerSize); +} .circle { - overflow: hidden; + position: relative; border-radius: 50%; - background: v-bind(background); + background: v-bind(backgroundCircle); clip-path: polygon(0 0, 0 100%, 50% 50%, 50% 50%, 100% 100%, 100% 0); } .selected { position: absolute; - z-index: 100; + z-index: 3; top: 0; left: 0; width: 100%; height: 100%; background: v-bind(conicGradient); } +.background { + width: v-bind(backgroundSize); + height: v-bind(backgroundSize); + position: absolute; + top: 50%; + left: 50%; + z-index: 4; + background: v-bind(background); + border-radius: 50%; + transform: translate(-50%, -50%); +} .count { position: absolute; top: 50%; left: 50%; + z-index: 5; transform: translate(-50%, -50%); + user-select: none; +} +.buttons { + display: flex; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + z-index: 5; } </style> diff --git a/src/stories/components/Knob/helpers.ts b/src/stories/components/Knob/helpers.ts new file mode 100644 index 0000000..abc880e --- /dev/null +++ b/src/stories/components/Knob/helpers.ts @@ -0,0 +1,61 @@ +import { convertThemeToColor } from '@helpers/common'; +import { EThemeColor, type TDarkness, type TSize, type TThemeColor } from '@interfaces/common'; +import type { IKnobColorGap } from '@interfaces/componentsProp'; + +export const calcNewValue = ( + event: MouseEvent, + center: number[], + start: number[], + degreesTotal: number, + length: number, + step: number, + value: number, +) => { + const x = event.clientX; + const y = event.clientY; + const a = Math.sqrt(Math.pow(x - center[0], 2) + Math.pow(y - center[1], 2)); + const b = Math.sqrt(Math.pow(x - start[0], 2) + Math.pow(y - start[1], 2)); + const c = Math.sqrt(Math.pow(center[0] - start[0], 2) + Math.pow(center[1] - start[1], 2)); + + let angle = (Math.acos((Math.pow(a, 2) - Math.pow(b, 2) + Math.pow(c, 2)) / (2 * a * c)) * 180) / Math.PI; + + const emptyDegreesHalf = (360 - degreesTotal) / 2; + if (angle < emptyDegreesHalf) return value; + if (x > center[0]) angle = 360 - angle; + + return Math.round((angle - emptyDegreesHalf) / ((degreesTotal / length) * step)) * step; +}; + +export const calcCenter = (container: Element) => { + const clientRect = container.getBoundingClientRect(); + return [ + clientRect.left + (clientRect.right - clientRect.left) / 2, + clientRect.top + (clientRect.bottom - clientRect.top) / 2, + ]; +}; + +export const calcStart = (container: Element) => { + const clientRect = container.getBoundingClientRect(); + return [clientRect.left + (clientRect.right - clientRect.left) / 2, clientRect.bottom]; +}; + +export const calcThemeColor = ( + colorGaps: IKnobColorGap[], + theme: TThemeColor, + darknessTheme: TDarkness, + value: number, +) => { + if (!colorGaps) return convertThemeToColor(theme, darknessTheme); + const current = colorGaps.find((item) => item.start <= value && value <= item.end); + if (!current) return convertThemeToColor(theme, darknessTheme); + return current.color in EThemeColor + ? convertThemeToColor(current.color, current.darknessColor ?? '500') + : current.color; +}; + +export const calcContainerSize = (size: TSize) => { + if (size === 'normal') return '100px'; + if (size === 'large') return '150px'; + if (size === 'huge') return '200px'; + return '70px'; +}; -- GitLab