mirror of
https://github.com/directus/directus.git
synced 2026-01-28 05:18:13 -05:00
Radios (#467)
* Extract custom values logic into shared composition * Fix background layering * Add disabled prop to v-icon * Pass field width to interfaces * Move color var declaration to global scope * Remove unused imports * Handle null for items * Add radio buttons string * Add radio buttons interface * Finish radio buttons interface * Add tests * Fix icon test
This commit is contained in:
@@ -151,7 +151,6 @@ export default defineComponent({
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
padding: 10px; // 14 - 4 (border)
|
||||
background-color: var(--page-background);
|
||||
border: 2px solid var(--background-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
? field.default_value
|
||||
: values[field.field]
|
||||
"
|
||||
:width="field.width"
|
||||
@input="setValue(field, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -47,17 +47,18 @@ Oftentimes, you'll use the icon next to some text, for example in a button. When
|
||||
```
|
||||
|
||||
## Props
|
||||
| Name | Description | Default |
|
||||
|-----------|-------------------------------------------------------------------|----------------|
|
||||
| `name`* | Name of the icon | -- |
|
||||
| `outline` | Use outline Material Icons. Note: only works for non-custom icons | `false` |
|
||||
| `size` | Custom pixel size | `false` |
|
||||
| `x-small` | Render the icon extra small | `false` |
|
||||
| `small` | Render the icon small | `false` |
|
||||
| `large` | Render the icon large | `false` |
|
||||
| `x-large` | Render the icon extra large | `false` |
|
||||
| `left` | Use when icon is left of text | `false` |
|
||||
| `right` | Use when icon is right of text | `false` |
|
||||
| Name | Description | Default |
|
||||
|-------------|-------------------------------------------------------------------|---------|
|
||||
| `name`* | Name of the icon | -- |
|
||||
| `outline` | Use outline Material Icons. Note: only works for non-custom icons | `false` |
|
||||
| `size` | Custom pixel size | `false` |
|
||||
| `x-small` | Render the icon extra small | `false` |
|
||||
| `small` | Render the icon small | `false` |
|
||||
| `large` | Render the icon large | `false` |
|
||||
| `x-large` | Render the icon extra large | `false` |
|
||||
| `left` | Use when icon is left of text | `false` |
|
||||
| `right` | Use when icon is right of text | `false` |
|
||||
| `disabledd` | Prevent the click handler from firing | `false` |
|
||||
|
||||
## Events
|
||||
| Event | Description | Data |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span
|
||||
class="v-icon"
|
||||
:class="[sizeClass, { 'has-click': hasClick, left, right }]"
|
||||
:class="[sizeClass, { 'has-click': !disabled && hasClick, left, right }]"
|
||||
:role="hasClick ? 'button' : null"
|
||||
@click="emitClick"
|
||||
:tabindex="hasClick ? 0 : null"
|
||||
@@ -55,6 +55,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
...sizeProps,
|
||||
},
|
||||
|
||||
@@ -80,17 +84,22 @@ export default defineComponent({
|
||||
};
|
||||
|
||||
function emitClick(event: MouseEvent) {
|
||||
if (props.disabled) return;
|
||||
emit('click', event);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-icon {
|
||||
<style>
|
||||
:root {
|
||||
--v-icon-color: currentColor;
|
||||
--v-icon-size: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-icon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: var(--v-icon-size);
|
||||
|
||||
@@ -70,12 +70,16 @@ export default defineComponent({
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--v-radio-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/no-wrap';
|
||||
|
||||
.v-radio {
|
||||
--v-radio-color: var(--primary);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0;
|
||||
@@ -112,7 +116,6 @@ export default defineComponent({
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
padding: 10px; // 14 - 4 (border)
|
||||
background-color: var(--page-background);
|
||||
border: 2px solid var(--background-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
|
||||
@@ -99,9 +99,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed, ref, watch } from '@vue/composition-api';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { defineComponent, PropType, computed, toRefs, Ref } from '@vue/composition-api';
|
||||
import i18n from '@/lang';
|
||||
import {
|
||||
useCustomSelection,
|
||||
useCustomSelectionMultiple,
|
||||
} from '@/compositions/use-custom-selection';
|
||||
|
||||
type Item = {
|
||||
text: string;
|
||||
@@ -158,8 +161,17 @@ export default defineComponent({
|
||||
setup(props, { emit }) {
|
||||
const { _items } = useItems();
|
||||
const { displayValue } = useDisplayValue();
|
||||
const { otherValue, usesOtherValue } = useCustomValue();
|
||||
const { otherValues, addOtherValue, setOtherValue } = useMultipleCustomValues();
|
||||
const { value } = toRefs(props);
|
||||
const { otherValue, usesOtherValue } = useCustomSelection(
|
||||
value as Ref<string>,
|
||||
_items,
|
||||
emit
|
||||
);
|
||||
const { otherValues, addOtherValue, setOtherValue } = useCustomSelectionMultiple(
|
||||
value as Ref<string[]>,
|
||||
_items,
|
||||
emit
|
||||
);
|
||||
|
||||
return {
|
||||
_items,
|
||||
@@ -223,107 +235,6 @@ export default defineComponent({
|
||||
return _items.value.find((item) => item.value === value)?.['text'];
|
||||
}
|
||||
}
|
||||
|
||||
function useCustomValue() {
|
||||
const localOtherValue = ref('');
|
||||
|
||||
const otherValue = computed({
|
||||
get() {
|
||||
return localOtherValue.value;
|
||||
},
|
||||
set(newValue: string | null) {
|
||||
if (newValue === null) {
|
||||
localOtherValue.value = '';
|
||||
emit('input', null);
|
||||
} else {
|
||||
localOtherValue.value = newValue;
|
||||
emit('input', newValue);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const usesOtherValue = computed(() => {
|
||||
// Check if set value is one of the existing keys
|
||||
const values = _items.value.map((item) => item.value);
|
||||
return (
|
||||
props.value !== null &&
|
||||
props.value.length > 0 &&
|
||||
values.includes(props.value) === false
|
||||
);
|
||||
});
|
||||
|
||||
return { otherValue, usesOtherValue };
|
||||
}
|
||||
|
||||
function useMultipleCustomValues() {
|
||||
type OtherValue = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const otherValues = ref<OtherValue[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
if (newValue === null) return;
|
||||
if (Array.isArray(newValue) === false) return;
|
||||
|
||||
(newValue as string[]).forEach((value) => {
|
||||
const values = _items.value.map((item) => item.value);
|
||||
const existsInValues = values.includes(value) === true;
|
||||
|
||||
if (existsInValues === false) {
|
||||
const other = otherValues.value.map((o) => o.value);
|
||||
const existsInOtherValues = other.includes(value) === true;
|
||||
|
||||
if (existsInOtherValues === false) {
|
||||
addOtherValue(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return { otherValues, addOtherValue, setOtherValue };
|
||||
|
||||
function addOtherValue(value = '') {
|
||||
otherValues.value = [
|
||||
...otherValues.value,
|
||||
{
|
||||
key: nanoid(),
|
||||
value: value,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function setOtherValue(key: string, newValue: string | null) {
|
||||
const previousValue = otherValues.value.find((o) => o.key === key);
|
||||
|
||||
const valueWithoutPrevious = ((props.value || []) as string[]).filter(
|
||||
(val) => val !== previousValue?.value
|
||||
);
|
||||
|
||||
if (newValue === null) {
|
||||
otherValues.value = otherValues.value.filter((o) => o.key !== key);
|
||||
|
||||
if (valueWithoutPrevious.length === 0) {
|
||||
emit('input', null);
|
||||
} else {
|
||||
emit('input', valueWithoutPrevious);
|
||||
}
|
||||
} else {
|
||||
otherValues.value = otherValues.value.map((otherValue) => {
|
||||
if (otherValue.key === key) otherValue.value = newValue;
|
||||
return otherValue;
|
||||
});
|
||||
|
||||
const newEmitValue = [...valueWithoutPrevious, newValue];
|
||||
|
||||
emit('input', newEmitValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
4
src/compositions/use-custom-selection/index.ts
Normal file
4
src/compositions/use-custom-selection/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useCustomSelection, useCustomSelectionMultiple } from './use-custom-selection';
|
||||
|
||||
export { useCustomSelection, useCustomSelectionMultiple };
|
||||
export default { useCustomSelection, useCustomSelectionMultiple };
|
||||
11
src/compositions/use-custom-selection/readme.md
Normal file
11
src/compositions/use-custom-selection/readme.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# useCustomSelection
|
||||
|
||||
This is a very specific composition meant to be used in v-select and the radios / checkboxes interfaces.
|
||||
|
||||
It's the logic that keeps track of custom options in a selection module, like the custom values in a
|
||||
multi-select dropdown.
|
||||
|
||||
```ts
|
||||
useCustomSelection()
|
||||
```
|
||||
|
||||
121
src/compositions/use-custom-selection/use-custom-selection.ts
Normal file
121
src/compositions/use-custom-selection/use-custom-selection.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Ref, ref, computed, watch } from '@vue/composition-api';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type EmitFunction = (event: string, ...args: any[]) => void;
|
||||
|
||||
type Items = Readonly<
|
||||
Ref<
|
||||
| readonly {
|
||||
text: string;
|
||||
value: string;
|
||||
}[]
|
||||
| null
|
||||
>
|
||||
>;
|
||||
|
||||
export function useCustomSelection(currentValue: Ref<string>, items: Items, emit: EmitFunction) {
|
||||
const localOtherValue = ref('');
|
||||
|
||||
const otherValue = computed({
|
||||
get() {
|
||||
return localOtherValue.value;
|
||||
},
|
||||
set(newValue: string | null) {
|
||||
if (newValue === null) {
|
||||
localOtherValue.value = '';
|
||||
emit('input', null);
|
||||
} else {
|
||||
localOtherValue.value = newValue;
|
||||
emit('input', newValue);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const usesOtherValue = computed(() => {
|
||||
if (items.value === null) return false;
|
||||
|
||||
// Check if set value is one of the existing keys
|
||||
const values = items.value.map((item) => item.value);
|
||||
return (
|
||||
currentValue.value !== null &&
|
||||
currentValue.value.length > 0 &&
|
||||
values.includes(currentValue.value) === false
|
||||
);
|
||||
});
|
||||
|
||||
return { otherValue, usesOtherValue };
|
||||
}
|
||||
|
||||
export function useCustomSelectionMultiple(
|
||||
currentValues: Ref<string[]>,
|
||||
items: Items,
|
||||
emit: EmitFunction
|
||||
) {
|
||||
type OtherValue = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const otherValues = ref<OtherValue[]>([]);
|
||||
|
||||
watch(currentValues, (newValue) => {
|
||||
if (newValue === null) return;
|
||||
if (Array.isArray(newValue) === false) return;
|
||||
if (items.value === null) return;
|
||||
|
||||
(newValue as string[]).forEach((value) => {
|
||||
if (items.value === null) return;
|
||||
const values = items.value.map((item) => item.value);
|
||||
const existsInValues = values.includes(value) === true;
|
||||
|
||||
if (existsInValues === false) {
|
||||
const other = otherValues.value.map((o) => o.value);
|
||||
const existsInOtherValues = other.includes(value) === true;
|
||||
|
||||
if (existsInOtherValues === false) {
|
||||
addOtherValue(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { otherValues, addOtherValue, setOtherValue };
|
||||
|
||||
function addOtherValue(value = '') {
|
||||
otherValues.value = [
|
||||
...otherValues.value,
|
||||
{
|
||||
key: nanoid(),
|
||||
value: value,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function setOtherValue(key: string, newValue: string | null) {
|
||||
const previousValue = otherValues.value.find((o) => o.key === key);
|
||||
|
||||
const valueWithoutPrevious = ((currentValues.value || []) as string[]).filter(
|
||||
(val) => val !== previousValue?.value
|
||||
);
|
||||
|
||||
if (newValue === null) {
|
||||
otherValues.value = otherValues.value.filter((o) => o.key !== key);
|
||||
|
||||
if (valueWithoutPrevious.length === 0) {
|
||||
emit('input', null);
|
||||
} else {
|
||||
emit('input', valueWithoutPrevious);
|
||||
}
|
||||
} else {
|
||||
otherValues.value = otherValues.value.map((otherValue) => {
|
||||
if (otherValue.key === key) otherValue.value = newValue;
|
||||
return otherValue;
|
||||
});
|
||||
|
||||
const newEmitValue = [...valueWithoutPrevious, newValue];
|
||||
|
||||
emit('input', newEmitValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import InterfaceToggle from './toggle/';
|
||||
import InterfaceWYSIWYG from './wysiwyg/';
|
||||
import InterfaceDropdown from './dropdown/';
|
||||
import InterfaceDropdownMultiselect from './dropdown-multiselect/';
|
||||
import InterfaceRadioButtons from './radio-buttons';
|
||||
|
||||
export const interfaces = [
|
||||
InterfaceTextInput,
|
||||
@@ -18,6 +19,7 @@ export const interfaces = [
|
||||
InterfaceWYSIWYG,
|
||||
InterfaceDropdown,
|
||||
InterfaceDropdownMultiselect,
|
||||
InterfaceRadioButtons,
|
||||
];
|
||||
|
||||
export default interfaces;
|
||||
|
||||
46
src/interfaces/radio-buttons/index.ts
Normal file
46
src/interfaces/radio-buttons/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { defineInterface } from '@/interfaces/define';
|
||||
import InterfaceRadioButtons from './radio-buttons.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'radio-buttons',
|
||||
name: i18n.t('radio_buttons'),
|
||||
icon: 'radio_button_checked',
|
||||
component: InterfaceRadioButtons,
|
||||
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)',
|
||||
},
|
||||
],
|
||||
}));
|
||||
72
src/interfaces/radio-buttons/radio-buttons.story.ts
Normal file
72
src/interfaces/radio-buttons/radio-buttons.story.ts
Normal 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 / Radio Buttons',
|
||||
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)', 'radio_button_checked'),
|
||||
},
|
||||
iconOff: {
|
||||
default: text('Icon (Off)', 'radio_button_unchecked'),
|
||||
},
|
||||
disabled: {
|
||||
default: boolean('Disabled', false),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const value = ref(null);
|
||||
return { value };
|
||||
},
|
||||
template: `
|
||||
<div :style="{
|
||||
maxWidth: fieldWidth === 'half' ? '300px' : '632px'
|
||||
}">
|
||||
<interface-radio-buttons
|
||||
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>
|
||||
`,
|
||||
});
|
||||
127
src/interfaces/radio-buttons/radio-buttons.test.ts
Normal file
127
src/interfaces/radio-buttons/radio-buttons.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import InterfaceRadioButtons from './radio-buttons.vue';
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import VRadio from '@/components/v-radio';
|
||||
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-radio', VRadio);
|
||||
localVue.component('v-icon', VIcon);
|
||||
localVue.component('v-notice', VNotice);
|
||||
|
||||
describe('Interfaces / Radio Buttons', () => {
|
||||
it('Returns null for items if choices arent set', () => {
|
||||
const component = shallowMount(InterfaceRadioButtons, {
|
||||
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(InterfaceRadioButtons, {
|
||||
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');
|
||||
});
|
||||
|
||||
it('Calculates what item to use based on the custom value set', async () => {
|
||||
const component = shallowMount(InterfaceRadioButtons, {
|
||||
i18n,
|
||||
localVue,
|
||||
propsData: {
|
||||
value: null,
|
||||
allowOther: true,
|
||||
choices: `
|
||||
option1
|
||||
option2
|
||||
`,
|
||||
iconOn: 'person',
|
||||
iconOff: 'settings',
|
||||
},
|
||||
});
|
||||
|
||||
expect((component.vm as any).customIcon).toBe('add');
|
||||
|
||||
(component.vm as any).otherValue = 'test';
|
||||
await component.vm.$nextTick();
|
||||
expect((component.vm as any).customIcon).toBe('settings');
|
||||
|
||||
(component.vm as any).otherValue = 'test';
|
||||
component.setProps({ value: 'test' });
|
||||
await component.vm.$nextTick();
|
||||
expect((component.vm as any).customIcon).toBe('person');
|
||||
});
|
||||
});
|
||||
211
src/interfaces/radio-buttons/radio-buttons.vue
Normal file
211
src/interfaces/radio-buttons/radio-buttons.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<v-notice v-if="!items" warning>
|
||||
{{ $t('choices_option_configured_incorrectly') }}
|
||||
</v-notice>
|
||||
<div
|
||||
v-else
|
||||
class="radio-buttons"
|
||||
:class="gridClass"
|
||||
:style="{
|
||||
'--v-radio-color': color,
|
||||
}"
|
||||
>
|
||||
<v-radio
|
||||
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)"
|
||||
/>
|
||||
<div
|
||||
class="custom"
|
||||
v-if="allowOther"
|
||||
:class="{
|
||||
active: !disabled && usesOtherValue,
|
||||
'has-value': !disabled && otherValue,
|
||||
disabled,
|
||||
}"
|
||||
>
|
||||
<v-icon :disabled="disabled" :name="customIcon" @click="$emit('input', otherValue)" />
|
||||
<input
|
||||
v-model="otherValue"
|
||||
:placeholder="$t('other')"
|
||||
:disabled="disabled"
|
||||
@focus="$emit('input', otherValue)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, toRefs } from '@vue/composition-api';
|
||||
import parseChoices from '@/utils/parse-choices';
|
||||
import { useCustomSelection } from '@/compositions/use-custom-selection';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
choices: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
allowOther: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
iconOn: {
|
||||
type: String,
|
||||
default: 'radio_button_checked',
|
||||
},
|
||||
iconOff: {
|
||||
type: String,
|
||||
default: 'radio_button_unchecked',
|
||||
},
|
||||
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 { otherValue, usesOtherValue } = useCustomSelection(value, items, emit);
|
||||
|
||||
const customIcon = computed(() => {
|
||||
if (!otherValue.value) return 'add';
|
||||
if (otherValue.value && usesOtherValue.value === true) return props.iconOn;
|
||||
return props.iconOff;
|
||||
});
|
||||
|
||||
return { items, gridClass, otherValue, usesOtherValue, customIcon };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.radio-buttons {
|
||||
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);
|
||||
}
|
||||
|
||||
.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>
|
||||
11
src/interfaces/radio-buttons/readme.md
Normal file
11
src/interfaces/radio-buttons/readme.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Radio Buttons
|
||||
|
||||
## 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)` |
|
||||
@@ -119,6 +119,8 @@
|
||||
"item_count": "No Items | One Item | {count} Items",
|
||||
"all_items": "All Items",
|
||||
|
||||
"radio_buttons": "Radio Buttons",
|
||||
|
||||
"users": "Users",
|
||||
"files": "Files",
|
||||
"activity": "Activity",
|
||||
|
||||
Reference in New Issue
Block a user