Color Interface (#531)

* styling for color inputs....messy

* values line up

* color swatch

* color picker working html

* hsl

* hsl works!

* added preset option kinda (needs repeater)

* added color preset colors pretty

* Fix input up/down

* Remove empty test file

* Remove deprecated prop on v-menu

* fixed portal and click outside

* Add color placeholder text

* Emit color value on every type of change

* Rename middleware

* Remove empty readme

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Jacob Rienstra
2020-05-13 19:13:11 -04:00
committed by GitHub
parent bac71cdc48
commit 536967553c
15 changed files with 543 additions and 19 deletions

View File

@@ -27,16 +27,16 @@
/>
</slot>
<span v-if="suffix" class="suffix">{{ suffix }}</span>
<span v-if="(type === 'number')">
<span v-if="type === 'number' && !hideArrows">
<v-icon
:class="{ disabled: value >= max }"
:class="{ disabled: max !== null && parseInt(value, 10) >= max }"
name="keyboard_arrow_up"
class="step-up"
@click="stepUp"
:disabled="disabled"
/>
<v-icon
:class="{ disabled: value <= min }"
:class="{ disabled: min !== null && parseInt(value, 10) <= min }"
name="keyboard_arrow_down"
class="step-down"
@click="stepDown"
@@ -97,6 +97,10 @@ export default defineComponent({
default: 'text',
},
// For number inputs only
hideArrows: {
type: Boolean,
default: false,
},
max: {
type: Number,
default: null,
@@ -190,20 +194,26 @@ export default defineComponent({
function stepUp() {
if (!input.value) return;
if (props.disabled === true) return;
if (props.max !== null && props.value >= props.max) return;
if (props.value < props.max) {
input.value.stepUp();
emit('input', input.value.value ?? props.min ?? 0);
input.value.stepUp();
if (input.value.value) {
return emit('input', input.value.value);
}
}
function stepDown() {
if (!input.value) return;
if (props.disabled === true) return;
if (props.min !== null && props.value <= props.min) return;
if (props.value > props.min) {
input.value.stepDown();
emit('input', input.value.value);
input.value.stepDown();
if (input.value.value) {
return emit('input', input.value.value);
} else {
return emit('input', props.min || 0);
}
}
},

View File

@@ -23,6 +23,7 @@
v-if="isActive"
v-click-outside="{
handler: deactivate,
middleware: onClickOutsideMiddleware,
disabled: isActive === false || closeOnClick === false,
events: ['click'],
}"
@@ -129,6 +130,7 @@ export default defineComponent({
toggle,
deactivate,
onContentClick,
onClickOutsideMiddleware,
styles,
arrowStyles,
popperPlacement,
@@ -176,6 +178,10 @@ export default defineComponent({
}
}
function onClickOutsideMiddleware(e: Event) {
return !activator.value?.contains(e.target as Node);
}
function onContentClick() {
if (props.closeOnContentClick === true) {
deactivate();

View File

@@ -0,0 +1,54 @@
import withPadding from '../../../.storybook/decorators/with-padding';
import { defineComponent, ref } from '@vue/composition-api';
import { withKnobs, array, boolean } from '@storybook/addon-knobs';
import readme from './readme.md';
import i18n from '@/lang';
import RawValue from '../../../.storybook/raw-value.vue';
export default {
title: 'Interfaces / Color',
decorators: [withPadding, withKnobs],
parameters: {
notes: readme,
},
};
export const basic = () =>
defineComponent({
i18n,
components: { RawValue },
props: {
disabled: {
default: boolean('Disabled', false),
},
presets: {
default: array('Preset Colors', [
'#EB5757',
'#F2994A',
'#F2C94C',
'#6FCF97',
'#27AE60',
'#56CCF2',
'#2F80ED',
'#9B51E0',
'#BB6BD9',
'#607D8B',
]),
},
},
setup() {
const value = ref('');
return { value };
},
template: `
<div style="max-width: 300px;">
<interface-color
v-model="value"
:presets="presets"
:disabled="disabled"
/>
<portal-target multiple name="outlet" />
<raw-value>{{ value }}</raw-value>
</div>
`,
});

View File

@@ -0,0 +1,307 @@
<template>
<v-menu attached :disabled="disabled">
<template #activator="{ toggle, active, activate }">
<v-input
:disabled="disabled"
:placeholder="$t('choose_a_color')"
v-model="hexValue"
@focus="activate"
:pattern="/#([a-f\d]{2}){3}/i"
class="color-input"
>
<template #prepend>
<v-input
type="color"
class="html-color-select"
v-model="hexValue"
ref="htmlColorInput"
/>
<v-button
@click="activateColorPicker"
class="swatch"
:icon="true"
:style="{
'--v-button-background-color': isValidColor ? hexValue : 'transparent',
border: isValidColor
? 'none'
: 'var(--border-width) solid var(--border-normal)',
}"
>
<v-icon v-if="!isValidColor" name="colorize" />
</v-button>
</template>
<template #append>
<v-icon name="palette" />
</template>
</v-input>
</template>
<div class="color-data-inputs">
<div class="color-data-input color-type">
<v-select :items="colorTypes" v-model="colorType" />
</div>
<template v-if="colorType === 'RGB'">
<v-input
:value="rgb.r"
@input="rgb = { ...rgb, r: $event }"
class="color-data-input"
type="number"
hideArrows
:min="0"
:max="255"
:step="1"
/>
<v-input
:value="rgb.g"
@input="rgb = { ...rgb, g: $event }"
class="color-data-input"
type="number"
hideArrows
:min="0"
:max="255"
:step="1"
/>
<v-input
:value="rgb.b"
@input="rgb = { ...rgb, b: $event }"
class="color-data-input"
type="number"
hideArrows
:min="0"
:max="255"
:step="1"
/>
</template>
<template v-if="colorType === 'HSL'">
<v-input
:value="hsl.h"
@input="hsl = { ...hsl, h: $event }"
class="color-data-input"
type="number"
hideArrows
:min="0"
:max="360"
:step="1"
/>
<v-input
:value="hsl.s"
@input="hsl = { ...hsl, s: $event }"
class="color-data-input"
type="number"
hideArrows
:min="0"
:max="100"
:step="1"
/>
<v-input
:value="hsl.l"
@input="hsl = { ...hsl, l: $event }"
class="color-data-input"
type="number"
hideArrows
:min="0"
:max="100"
:step="1"
/>
</template>
</div>
<div class="presets" v-if="presets">
<v-button
v-for="preset in presets"
:key="preset"
class="preset"
rounded
icon
:style="{ '--v-button-background-color': preset }"
@click="() => (hexValue = preset)"
/>
</div>
</v-menu>
</template>
<script lang="ts">
import { defineComponent, ref, computed, PropType, watch } from '@vue/composition-api';
import color, { RGB, HSL } from '@/utils/color';
export default defineComponent({
props: {
disabled: {
type: Boolean,
default: false,
},
value: {
type: String,
default: null,
validator: (val: string) => val === null || val === '' || color.isHex(val),
},
presets: {
type: Array as PropType<string[]>,
default: [
'#EB5757',
'#F2994A',
'#F2C94C',
'#6FCF97',
'#27AE60',
'#56CCF2',
'#2F80ED',
'#9B51E0',
'#BB6BD9',
'#607D8B',
],
},
},
setup(props, { emit }) {
const htmlColorInput = ref<Vue>(null);
type ColorType = 'RGB' | 'HSL';
const colorTypes = ['RGB', 'HSL'] as ColorType[];
const colorType = ref<ColorType>('RGB');
function activateColorPicker() {
(htmlColorInput.value?.$el as HTMLElement).getElementsByTagName('input')[0].click();
}
const isValidColor = computed<boolean>(
() => hexValue.value != null && color.isHex(hexValue.value as string)
);
const { rgb, hsl, hexValue } = useColor(props.value);
return {
colorTypes,
colorType,
rgb,
hsl,
hexValue,
htmlColorInput,
activateColorPicker,
isValidColor,
};
function useColor(hex: string) {
const hexValue = ref<string | null>(hex);
watch(hexValue, (newHex) => {
if (!newHex) emit('input', null);
else if (newHex.length === 0) emit('input', null);
else if (newHex.length === 7) emit('input', newHex);
else emit('input', null);
});
const hsl = computed<HSL<string | null>>({
get() {
return color.hexToHsl(hexValue.value);
},
set(newHSL) {
hexValue.value = color.hslToHex(newHSL);
},
});
const rgb = computed<RGB<string | null>>({
get() {
return color.hexToRgb(hexValue.value);
},
set(newRGB) {
hexValue.value = color.rgbToHex(newRGB);
},
});
return { rgb, hsl, hexValue };
}
},
});
</script>
<style lang="scss" scoped>
.swatch {
/* --v-button-height: calc(var(--input-height) - 12px);
--v-button-width: calc(var(--input-height) - 12px);
--v-button-min-height: none;
--v-button-max-height: calc(var(--input-height) - 12px); */
--v-button-padding: 6px;
--v-button-background-color: transparent;
--v-button-background-color-hover: var(--v-button-background-color);
position: relative;
box-sizing: border-box;
width: calc(var(--input-height) - 12px);
max-height: calc(var(--input-height) - 12px);
overflow: hidden;
border-radius: var(--border-radius);
cursor: pointer;
}
.presets {
display: flex;
width: 100%;
padding: 0px 8px 14px 8px;
.preset {
--v-button-background-color-hover: var(--v-button-background-color);
--v-button-height: 20px;
--v-button-width: 20px;
padding: 0px 4px;
&:first-child {
padding-left: 0px;
}
&:last-child {
padding-right: 0px;
}
}
}
.v-input.html-color-select {
display: none;
}
.color-input {
--input-padding: 12px 12px 12px 4px;
}
.color-data-inputs {
display: grid;
grid-gap: 0px;
grid-template-columns: repeat(5, 1fr);
width: 100%;
padding: 12px 10px;
.color-type {
grid-column: 1 / span 2;
}
.color-data-input {
--border-radius: 0px;
&::v-deep .input:focus-within,
&::v-deep .input:active,
&::v-deep .input:focus,
&::v-deep .input:hover,
&::v-deep .input.active {
z-index: 1;
}
&:not(.color-type) {
--input-padding: 12px 8px;
}
&:not(:first-child)::v-deep .input {
margin-left: calc(-1 * var(--border-width));
}
/* stylelint-disable indentation */
&:not(:last-child)::v-deep
.input:not(.active):not(:focus-within):not(:hover):not(:active):not(:focus) {
border-right-color: transparent;
}
&:first-child {
--border-radius: 4px 0px 0px 4px;
}
&:last-child {
--border-radius: 0px 4px 4px 0px;
}
}
}
</style>

View File

@@ -0,0 +1,17 @@
import { defineInterface } from '@/interfaces/define';
import InterfaceColor from './color.vue';
export default defineInterface(({ i18n }) => ({
id: 'color',
name: i18n.t('color'),
icon: 'palette',
component: InterfaceColor,
options: [
{
field: 'presets',
name: 'Preset Colors',
width: 'full',
interface: 'repeater<color>',
},
],
}));

View File

@@ -47,6 +47,7 @@ export const basic = () =>
v-model="value"
:type="type"
/>
<portal-target multiple name="outlet" />
<raw-value>{{ value }}</raw-value>
</div>
`,

View File

@@ -10,7 +10,7 @@
:placeholder="$t('enter_a_value')"
>
<template #append>
<v-icon name="todate" :class="{ active }" />
<v-icon name="today" :class="{ active }" />
</template>
</v-input>
</template>

View File

@@ -56,6 +56,7 @@ trim :: Option D
:choices="choices"
:icon="icon"
/>
<portal-target multiple name="outlet" />
<raw-value>{{ value }}</raw-value>
</div>
`,

View File

@@ -56,6 +56,7 @@ trim :: Option D
:choices="choices"
:icon="icon"
/>
<portal-target name="outlet" />
<raw-value>{{ value }}</raw-value>
</div>
`,

View File

@@ -36,6 +36,7 @@ export const basic = () =>
template: `
<div style="width: 300px">
<interface-icon v-model="value" :disabled="disabled" />
<portal-target name="outlet" />
<raw-value>{{value}}</raw-value>
</div>
`,

View File

@@ -15,6 +15,7 @@ import InterfaceImage from './image';
import InterfaceIcon from './icon';
import InterfaceManyToOne from './many-to-one';
import InterfaceOneToMany from './one-to-many';
import InterfaceColor from './color';
import InterfaceHash from './hash';
import InterfaceSlug from './slug';
import InterfaceUser from './user';
@@ -38,6 +39,7 @@ export const interfaces = [
InterfaceIcon,
InterfaceManyToOne,
InterfaceOneToMany,
InterfaceColor,
InterfaceHash,
InterfaceSlug,
InterfaceUser,

View File

@@ -15,19 +15,19 @@ export default defineInterface(({ i18n }) => ({
interface: 'text-input',
},
{
field: 'minValue',
field: 'min',
name: i18n.t('minimum_value'),
width: 'half',
interface: 'numeric',
},
{
field: 'maxValue',
field: 'max',
name: i18n.t('maximum_value'),
width: 'half',
interface: 'numeric',
},
{
field: 'stepInterval',
field: 'step',
name: i18n.t('step_interval'),
width: 'half',
interface: 'numeric',

View File

@@ -5,9 +5,9 @@
:value="value"
:placeholder="placeholder"
:disabled="disabled"
:min="minValue"
:max="maxValue"
:step="stepInterval"
:min="min"
:max="max"
:step="step"
@input="$listeners.input"
>
<template v-if="iconLeft" #prepend>
@@ -32,11 +32,11 @@ export default defineComponent({
type: Boolean,
default: false,
},
minValue: {
min: {
type: Number,
default: null,
},
maxValue: {
max: {
type: Number,
default: null,
},
@@ -48,7 +48,7 @@ export default defineComponent({
type: String,
default: null,
},
stepInterval: {
step: {
type: Number,
default: 1,
},

View File

@@ -380,6 +380,8 @@
"none": "None",
"choose_a_color": "Choose a Color...",
"add_new_item": "Add New Item",
"many-to-one": "Many to One",

122
src/utils/color/index.ts Normal file
View File

@@ -0,0 +1,122 @@
export interface RGB<T> {
r: T;
g: T;
b: T;
}
export interface HSL<T> {
h: T;
s: T;
l: T;
}
export function isNullish(obj: RGB<string | null> | HSL<string | null>): boolean {
return Object.values(obj).every((e) => e === null);
}
export function isEmptyStringIsh(obj: RGB<string | null> | HSL<string | null>): boolean {
return Object.values(obj).every((e) => e === '');
}
export function componentToHex(c: number): string {
if (c > 255) return 'ff';
return c.toString(16).padStart(2, '0').toUpperCase();
}
export function toNum(x: string | number | null): number {
if (typeof x === 'string') {
const res = parseInt(x, 10);
return isNaN(res) ? 0 : res;
} else return x || 0;
}
export function rgbToHex(rgb: RGB<string | null>): string | null {
if (isNullish(rgb)) return null;
if (isEmptyStringIsh(rgb)) return '';
const r = componentToHex(toNum(rgb.r));
const g = componentToHex(toNum(rgb.g));
const b = componentToHex(toNum(rgb.b));
return `#${r}${g}${b}`;
}
export function isHex(hex: string): boolean {
return /^#(([a-f\d]{2}){3,4})$/i.test(hex);
}
export function hexToRgb(hex: string | null): RGB<string | null> {
if (hex === null) return { r: null, g: null, b: null };
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
if (!result) return { r: '', g: '', b: '' };
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
const a = result[4] ? parseInt(result[4], 16) / 255 : 1;
return { r: r.toString(), g: g.toString(), b: b.toString() };
}
export function hexToHsl(hex: string | null): HSL<string | null> {
return rgbToHsl(hexToRgb(hex));
}
export function hslToHex(hsl: HSL<string | null>): string | null {
return rgbToHex(hslToRgb(hsl));
}
// r,g,b in [0,255]; h in [0,360); s,l in [0,100]
export function rgbToHsl(rgb: RGB<string | null>): HSL<string | null> {
if (isNullish(rgb)) return { h: null, s: null, l: null };
if (isEmptyStringIsh(rgb)) return { h: '', s: '', l: '' };
let r = toNum(rgb.r);
let g = toNum(rgb.g);
let b = toNum(rgb.b);
// Make r, g, and b fractions of 1
r /= 255;
g /= 255;
b /= 255;
const a = Math.max(r, g, b),
n = a - Math.min(r, g, b),
f = 1 - Math.abs(a + a - n - 1);
let h = n && (a == r ? (g - b) / n : a == g ? 2 + (b - r) / n : 4 + (r - g) / n);
h = 60 * (h < 0 ? h + 6 : h);
let s = f ? n / f : 0;
let l = (a + a - n) / 2;
s *= 100;
l *= 100;
return {
h: Math.round(h).toString(),
s: Math.round(s).toString(),
l: Math.round(l).toString(),
};
}
// h in [0, 360); s,l in [0,100]
export function hslToRgb(hsl: HSL<string | null>): RGB<string | null> {
if (isNullish(hsl)) return { r: null, g: null, b: null };
if (isEmptyStringIsh(hsl)) return { r: '', g: '', b: '' };
const h = toNum(hsl.h);
let s = toNum(hsl.s);
let l = toNum(hsl.l);
// Must be fractions of 1
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
const r = Math.round(f(0) * 255);
const g = Math.round(f(8) * 255);
const b = Math.round(f(4) * 255);
return { r: r.toString(), g: g.toString(), b: b.toString() };
}
export default {
componentToHex,
isHex,
rgbToHex,
hexToRgb,
rgbToHsl,
hslToRgb,
hexToHsl,
hslToHex,
};