Checkboxes (#468)

* Allow custom value option

* Add checkboxes interface

* Style custom value checkbox better

* Fix tests
This commit is contained in:
Rijk van Zanten
2020-04-24 13:06:00 -04:00
committed by GitHub
parent 16c3804b0a
commit a7e938ae8d
8 changed files with 489 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
<template>
<button
<component
:is="customValue ? 'div' : 'button'"
class="v-checkbox"
@click="toggleInput"
type="button"
@@ -9,16 +10,18 @@
:class="{ checked: isChecked, indeterminate, block }"
>
<div class="prepend" v-if="$scopedSlots.prepend"><slot name="prepend" /></div>
<v-icon class="checkbox" :name="icon" />
<v-icon class="checkbox" :name="icon" @click.stop="toggleInput" />
<span class="label type-text">
<slot name="label">{{ label }}</slot>
<slot name="label" v-if="customValue === false">{{ label }}</slot>
<input @click.stop class="custom-input" v-else v-model="_value" />
</span>
<div class="append" v-if="$scopedSlots.append"><slot name="append" /></div>
</button>
</component>
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
import useSync from '../../compositions/use-sync';
export default defineComponent({
model: {
@@ -62,8 +65,14 @@ export default defineComponent({
type: Boolean,
default: false,
},
customValue: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const _value = useSync(props, 'value', emit);
const isChecked = computed<boolean>(() => {
if (props.inputValue instanceof Array) {
return props.inputValue.includes(props.value);
@@ -77,7 +86,7 @@ export default defineComponent({
return isChecked.value ? props.iconOn : props.iconOff;
});
return { isChecked, toggleInput, icon };
return { isChecked, toggleInput, icon, _value };
function toggleInput(): void {
if (props.indeterminate === true) {
@@ -102,12 +111,16 @@ export default defineComponent({
});
</script>
<style>
:root {
--v-checkbox-color: var(--primary);
}
</style>
<style lang="scss" scoped>
@import '@/styles/mixins/no-wrap';
.v-checkbox {
--v-checkbox-color: var(--primary);
position: relative;
display: flex;
align-items: center;
@@ -121,6 +134,14 @@ export default defineComponent({
.label:not(:empty) {
margin-left: 8px;
input {
width: 100%;
background-color: transparent;
border: none;
border-bottom: 2px solid var(--border-normal);
border-radius: 0;
}
@include no-wrap;
}
@@ -184,6 +205,10 @@ export default defineComponent({
opacity: 0.1;
}
}
input {
border-color: var(--v-checkbox-color);
}
}
.prepend,

View File

@@ -0,0 +1,72 @@
import withPadding from '../../../.storybook/decorators/with-padding';
import { defineComponent, ref } from '@vue/composition-api';
import { boolean, withKnobs, text, select, color } from '@storybook/addon-knobs';
import readme from './readme.md';
import i18n from '@/lang';
import RawValue from '../../../.storybook/raw-value.vue';
export default {
title: 'Interfaces / Checkboxes',
decorators: [withPadding, withKnobs],
parameters: {
notes: readme,
},
};
export const basic = () =>
defineComponent({
components: { RawValue },
i18n,
props: {
allowOther: {
default: boolean('Allow Other', false),
},
choices: {
default: text(
'Choices',
`
Option A
Option B
custom_value::Option C
trim :: Option D
`
),
},
fieldWidth: {
default: select('Field Width', ['half', 'full'], 'full'),
},
color: {
default: color('Color', '#2f80ed'),
},
iconOn: {
default: text('Icon (On)', 'check_box'),
},
iconOff: {
default: text('Icon (Off)', 'check_box_outline_blank'),
},
disabled: {
default: boolean('Disabled', false),
},
},
setup() {
const value = ref(null);
return { value };
},
template: `
<div :style="{
maxWidth: fieldWidth === 'half' ? '300px' : '632px'
}">
<interface-checkboxes
v-model="value"
:allow-other="allowOther"
:choices="choices"
:width="fieldWidth"
:color="color"
:icon-on="iconOn"
:icon-off="iconOff"
:disabled="disabled"
/>
<raw-value>{{ value }}</raw-value>
</div>
`,
});

View File

@@ -0,0 +1,99 @@
import InterfaceCheckboxes from './checkboxes.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import VCheckbox from '@/components/v-checkbox';
import VIcon from '@/components/v-icon';
import VNotice from '@/components/v-notice';
import i18n from '@/lang';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-checkbox', VCheckbox);
localVue.component('v-icon', VIcon);
localVue.component('v-notice', VNotice);
describe('Interfaces / Checkboxes', () => {
it('Returns null for items if choices arent set', () => {
const component = shallowMount(InterfaceCheckboxes, {
localVue,
i18n,
listeners: {
input: () => undefined,
},
propsData: {
choices: null,
},
});
expect((component.vm as any).items).toBe(null);
});
it('Calculates the grid size based on interface width and longest option', () => {
const component = shallowMount(InterfaceCheckboxes, {
localVue,
i18n,
listeners: {
input: () => undefined,
},
propsData: {
choices: null,
},
});
expect((component.vm as any).gridClass).toBe(null);
component.setProps({
width: 'half',
choices: `
Short
`,
});
expect((component.vm as any).gridClass).toBe('grid-2');
component.setProps({
width: 'half',
choices: `
Super long choice means single column
`,
});
expect((component.vm as any).gridClass).toBe('grid-1');
component.setProps({
width: 'full',
choices: `
< 10 = 4
`,
});
expect((component.vm as any).gridClass).toBe('grid-4');
component.setProps({
width: 'full',
choices: `
10 to 15 uses 3
`,
});
expect((component.vm as any).gridClass).toBe('grid-3');
component.setProps({
width: 'full',
choices: `
15 to 25 chars uses 2
`,
});
expect((component.vm as any).gridClass).toBe('grid-2');
component.setProps({
width: 'full',
choices: `
Super long choice means single column
`,
});
expect((component.vm as any).gridClass).toBe('grid-1');
});
});

View File

@@ -0,0 +1,226 @@
<template>
<v-notice v-if="!items" warning>
{{ $t('choices_option_configured_incorrectly') }}
</v-notice>
<div
v-else
class="checkboxes"
:class="gridClass"
:style="{
'--v-checkbox-color': color,
}"
>
<v-checkbox
block
v-for="item in items"
:key="item.value"
:value="item.value"
:label="item.text"
:disabled="disabled"
:icon-on="iconOn"
:icon-off="iconOff"
:input-value="value || []"
@change="$emit('input', $event)"
/>
<template v-if="allowOther">
<button
:disabled="disabled"
v-if="allowOther"
class="add-new custom"
align="left"
outlined
dashed
secondary
@click="addOtherValue()"
>
<v-icon name="add" />
{{ $t('other') }}
</button>
<v-checkbox
block
custom-value
v-for="otherValue in otherValues"
:key="otherValue.key"
:value="otherValue.value"
:disabled="disabled"
:icon-on="iconOn"
:icon-off="iconOff"
:input-value="value || []"
@update:value="setOtherValue(otherValue.key, $event)"
@change="$emit('input', $event)"
/>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, toRefs, PropType } from '@vue/composition-api';
import parseChoices from '@/utils/parse-choices';
import { useCustomSelectionMultiple } from '@/compositions/use-custom-selection';
export default defineComponent({
props: {
disabled: {
type: Boolean,
default: false,
},
value: {
type: Array as PropType<string[]>,
default: null,
},
choices: {
type: String,
default: null,
},
allowOther: {
type: Boolean,
default: false,
},
width: {
type: String,
default: null,
},
iconOn: {
type: String,
default: 'check_box',
},
iconOff: {
type: String,
default: 'check_box_outline_blank',
},
color: {
type: String,
default: 'var(--primary)',
},
},
setup(props, { emit }) {
const { value } = toRefs(props);
const items = computed(() => {
if (props.choices === null || props.choices.length === 0) return null;
return parseChoices(props.choices);
});
const gridClass = computed(() => {
if (items.value === null) return null;
const widestOptionLength = items.value.reduce((acc, val) => {
if (val.text.length > acc.length) acc = val.text;
return acc;
}, '').length;
if (props.width?.startsWith('half')) {
if (widestOptionLength <= 10) return 'grid-2';
return 'grid-1';
}
if (widestOptionLength <= 10) return 'grid-4';
if (widestOptionLength > 10 && widestOptionLength <= 15) return 'grid-3';
if (widestOptionLength > 15 && widestOptionLength <= 25) return 'grid-2';
return 'grid-1';
});
const { otherValues, addOtherValue, setOtherValue } = useCustomSelectionMultiple(
value,
items,
emit
);
return { items, gridClass, otherValues, addOtherValue, setOtherValue };
},
});
</script>
<style lang="scss" scoped>
.checkboxes {
display: grid;
grid-gap: 12px 32px;
}
.grid-1 {
grid-template-columns: repeat(1, 1fr);
}
.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
.grid-4 {
grid-template-columns: repeat(4, 1fr);
}
.add-new {
--v-button-min-width: none;
}
.custom {
--v-icon-color: var(--foreground-subdued);
display: flex;
align-items: center;
width: 100%;
height: var(--input-height);
padding: 10px;
border: 2px dashed var(--border-normal);
border-radius: var(--border-radius);
input {
display: block;
flex-grow: 1;
width: 20px; // this will auto grow with flex above
margin: 0;
margin-left: 8px;
padding: 0;
background-color: transparent;
border: none;
border-radius: 0;
}
&.has-value {
background-color: var(--background-subdued);
border: 2px solid var(--background-subdued);
}
&.active {
--v-icon-color: var(--v-radio-color);
position: relative;
background-color: transparent;
border-color: var(--v-radio-color);
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--v-radio-color);
opacity: 0.1;
content: '';
pointer-events: none;
}
}
&.disabled {
background-color: var(--background-subdued);
border-color: transparent;
cursor: not-allowed;
input {
color: var(--foreground-subdued);
cursor: not-allowed;
&::placeholder {
color: var(--foreground-subdued);
}
}
}
}
</style>

View File

@@ -0,0 +1,46 @@
import { defineInterface } from '@/interfaces/define';
import InterfaceCheckboxes from './checkboxes.vue';
export default defineInterface(({ i18n }) => ({
id: 'checkboxes',
name: i18n.t('checkboxes'),
icon: 'radio_button_checked',
component: InterfaceCheckboxes,
options: [
{
field: 'choices',
name: i18n.t('choices'),
note: i18n.t('use_double_colon_for_key'),
width: 'full',
interface: 'textarea',
},
{
field: 'allowOther',
name: i18n.t('allow_other'),
width: 'half',
interface: 'toggle',
default_value: false,
},
{
field: 'iconOff',
name: i18n.t('icon_off'),
width: 'half',
interface: 'icon',
default_value: 'check_box_outline_blank',
},
{
field: 'iconOn',
name: i18n.t('icon_on'),
width: 'half',
interface: 'icon',
default_value: 'check_box',
},
{
field: 'color',
name: i18n.t('color'),
width: 'half',
interface: 'color',
default_value: 'var(--primary)',
},
],
}));

View File

@@ -0,0 +1,11 @@
# Checkboxes
## Options
| Option | Description | Default |
|---------------|-----------------------------------------------------|--------------------------|
| `choices` | What choices to render as radios | `null` |
| `allow-other` | Allow the user to enter a custom value | `false` |
| `icon-on` | What icon to show when the radio is checked | `radio_button_checked` |
| `icon-off` | What icon to show when the radio is unchecked | `radio_button_unchecked` |
| `color` | What color to use for the active state of the radio | `var(--primary)` |

View File

@@ -8,6 +8,7 @@ import InterfaceWYSIWYG from './wysiwyg/';
import InterfaceDropdown from './dropdown/';
import InterfaceDropdownMultiselect from './dropdown-multiselect/';
import InterfaceRadioButtons from './radio-buttons';
import InterfaceCheckboxes from './checkboxes';
export const interfaces = [
InterfaceTextInput,
@@ -20,6 +21,7 @@ export const interfaces = [
InterfaceDropdown,
InterfaceDropdownMultiselect,
InterfaceRadioButtons,
InterfaceCheckboxes,
];
export default interfaces;

View File

@@ -120,6 +120,7 @@
"all_items": "All Items",
"radio_buttons": "Radio Buttons",
"checkboxes": "Checkboxes",
"users": "Users",
"files": "Files",