Skip to content
Snippets Groups Projects
Commit a920a2dd authored by Дмитрий Малюгин's avatar Дмитрий Малюгин :clock4:
Browse files

feat: component 'Knob'

parent a6b4e0e0
No related branches found
No related tags found
1 merge request!3Table (partially), Checkbox, Tag, Select and Knob
......@@ -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" />
......
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);
......
......@@ -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'>;
......
......@@ -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;
......
......@@ -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;
......
......@@ -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,
},
};
<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>
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';
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment