diff --git a/src/components/v-input/v-input.vue b/src/components/v-input/v-input.vue index 61a79f68a3..7a396d15f2 100644 --- a/src/components/v-input/v-input.vue +++ b/src/components/v-input/v-input.vue @@ -27,16 +27,16 @@ /> {{ suffix }} - + = 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); } } }, diff --git a/src/components/v-menu/v-menu.vue b/src/components/v-menu/v-menu.vue index 6cf3a73c70..1a3d651d49 100644 --- a/src/components/v-menu/v-menu.vue +++ b/src/components/v-menu/v-menu.vue @@ -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(); diff --git a/src/interfaces/color/color.story.ts b/src/interfaces/color/color.story.ts new file mode 100644 index 0000000000..e3220d3bec --- /dev/null +++ b/src/interfaces/color/color.story.ts @@ -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: ` + + + + {{ value }} + + `, + }); diff --git a/src/interfaces/color/color.vue b/src/interfaces/color/color.vue new file mode 100644 index 0000000000..282a347c73 --- /dev/null +++ b/src/interfaces/color/color.vue @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + (hexValue = preset)" + /> + + + + + + diff --git a/src/interfaces/color/index.ts b/src/interfaces/color/index.ts new file mode 100644 index 0000000000..9d7b6a0769 --- /dev/null +++ b/src/interfaces/color/index.ts @@ -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', + }, + ], +})); diff --git a/src/interfaces/datetime/datetime.story.ts b/src/interfaces/datetime/datetime.story.ts index 40db466638..bd7fbd045d 100644 --- a/src/interfaces/datetime/datetime.story.ts +++ b/src/interfaces/datetime/datetime.story.ts @@ -47,6 +47,7 @@ export const basic = () => v-model="value" :type="type" /> + {{ value }} `, diff --git a/src/interfaces/datetime/datetime.vue b/src/interfaces/datetime/datetime.vue index 5d84435528..23fb5a3c8a 100644 --- a/src/interfaces/datetime/datetime.vue +++ b/src/interfaces/datetime/datetime.vue @@ -10,7 +10,7 @@ :placeholder="$t('enter_a_value')" > - + diff --git a/src/interfaces/dropdown-multiselect/dropdown-multiselect.story.ts b/src/interfaces/dropdown-multiselect/dropdown-multiselect.story.ts index 8412672071..99bbf260ee 100644 --- a/src/interfaces/dropdown-multiselect/dropdown-multiselect.story.ts +++ b/src/interfaces/dropdown-multiselect/dropdown-multiselect.story.ts @@ -56,6 +56,7 @@ trim :: Option D :choices="choices" :icon="icon" /> + {{ value }} `, diff --git a/src/interfaces/dropdown/dropdown.story.ts b/src/interfaces/dropdown/dropdown.story.ts index eacd172590..44764e953d 100644 --- a/src/interfaces/dropdown/dropdown.story.ts +++ b/src/interfaces/dropdown/dropdown.story.ts @@ -56,6 +56,7 @@ trim :: Option D :choices="choices" :icon="icon" /> + {{ value }} `, diff --git a/src/interfaces/icon/icon.story.ts b/src/interfaces/icon/icon.story.ts index fa49ea2d44..d716bc6e89 100644 --- a/src/interfaces/icon/icon.story.ts +++ b/src/interfaces/icon/icon.story.ts @@ -36,6 +36,7 @@ export const basic = () => template: ` + {{value}} `, diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 6d2fc8caf5..78034578bc 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -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, diff --git a/src/interfaces/numeric/index.ts b/src/interfaces/numeric/index.ts index 2970da08db..9a643a54de 100644 --- a/src/interfaces/numeric/index.ts +++ b/src/interfaces/numeric/index.ts @@ -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', diff --git a/src/interfaces/numeric/numeric.vue b/src/interfaces/numeric/numeric.vue index c6008fa90b..897f9cebbe 100644 --- a/src/interfaces/numeric/numeric.vue +++ b/src/interfaces/numeric/numeric.vue @@ -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" > @@ -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, }, diff --git a/src/lang/en-US/index.json b/src/lang/en-US/index.json index 43b486188e..391a7759c1 100644 --- a/src/lang/en-US/index.json +++ b/src/lang/en-US/index.json @@ -380,6 +380,8 @@ "none": "None", + "choose_a_color": "Choose a Color...", + "add_new_item": "Add New Item", "many-to-one": "Many to One", diff --git a/src/utils/color/index.ts b/src/utils/color/index.ts new file mode 100644 index 0000000000..844b5814a0 --- /dev/null +++ b/src/utils/color/index.ts @@ -0,0 +1,122 @@ +export interface RGB { + r: T; + g: T; + b: T; +} + +export interface HSL { + h: T; + s: T; + l: T; +} + +export function isNullish(obj: RGB | HSL): boolean { + return Object.values(obj).every((e) => e === null); +} + +export function isEmptyStringIsh(obj: RGB | HSL): 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 { + 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 { + 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 { + return rgbToHsl(hexToRgb(hex)); +} + +export function hslToHex(hsl: HSL): 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): HSL { + 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): RGB { + 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, +};