refactor(ui): replaced some n-input with c-input-text

This commit is contained in:
Corentin Thomasset 2023-05-14 21:26:18 +02:00 committed by Corentin THOMASSET
parent aad8d84e13
commit 77f2efc0b9
39 changed files with 738 additions and 448 deletions

View file

@ -25,6 +25,18 @@
>
A
</c-button>
<c-button
v-for="buttonType of buttonTypes"
:key="buttonType"
:variant="buttonVariant"
:type="buttonType"
:size="buttonSize"
circle
mx-1
>
<icon-mdi-content-copy />
</c-button>
</div>
</div>
</template>
@ -33,7 +45,7 @@
import _ from 'lodash';
const buttonVariants = ['basic', 'text'] as const;
const buttonTypes = ['default', 'primary'] as const;
const buttonTypes = ['default', 'primary', 'warning'] as const;
const buttonSizes = ['small', 'medium', 'large'] as const;
</script>

View file

@ -56,10 +56,10 @@ const createTheme = ({ style }: { style: 'light' | 'dark' }) => {
pressedBackground: darken(theme.primary.colorFaded, 30),
}),
warning: createState({
textColor: theme.text.baseColor,
backgroundColor: theme.warning.color,
hoverBackground: theme.warning.colorHover,
pressedBackground: theme.warning.colorPressed,
textColor: theme.warning.color,
backgroundColor: theme.warning.colorFaded,
hoverBackground: lighten(theme.warning.colorFaded, 30),
pressedBackground: darken(theme.warning.colorFaded, 30),
}),
},
text: {
@ -76,10 +76,10 @@ const createTheme = ({ style }: { style: 'light' | 'dark' }) => {
pressedBackground: darken(theme.primary.colorFaded, 30),
}),
warning: createState({
textColor: theme.text.baseColor,
backgroundColor: theme.warning.color,
hoverBackground: theme.warning.colorHover,
pressedBackground: theme.warning.colorPressed,
textColor: darken(theme.warning.color, 20),
backgroundColor: 'transparent',
hoverBackground: theme.warning.colorFaded,
pressedBackground: darken(theme.warning.colorFaded, 30),
}),
},
};

View file

@ -18,7 +18,7 @@ import { useAppTheme } from '../theme/themes';
const props = withDefaults(
defineProps<{
type?: 'default' | 'primary';
type?: 'default' | 'primary' | 'warning';
variant?: 'basic' | 'text';
disabled?: boolean;
round?: boolean;

View file

@ -2,6 +2,9 @@
<h2>Default</h2>
<c-input-text value="qsd" />
<c-input-text
value="Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, est modi iusto repellendus fuga accusantium atque at magnam aliquam eum explicabo vero quia, nobis quasi quis! Earum amet quam a?"
/>
<h2>With placeholder</h2>
@ -24,16 +27,50 @@
<h2>Validation</h2>
<c-input-text
v-model:value="value"
:validation-rules="[{ message: 'Length must be > 10', validator: (value) => value.length > 10 }]"
/>
<c-input-text v-model:value="value" :validation-rules="validationRules" mb-2 />
<c-input-text v-model:value="value" :validation-rules="validationRules" mb-2 label-position="left" label="Yo " />
<c-input-text v-model:value="value" :validation="validation" />
<c-input-text v-model:value="value" :validation="validation" multiline rows="3" />
<h2>Clearable</h2>
<c-input-text v-model:value="value" clearable />
<h2>Type password</h2>
<c-input-text value="value" type="password" />
<h2>Multiline</h2>
<c-input-text value="value" multiline label="Label" mb-2 rows="1" />
<c-input-text value="value" multiline label="Label" mb-2 />
<c-input-text
value="Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, est modi iusto repellendus fuga accusantium atque at magnam aliquam eum explicabo vero quia, nobis quasi quis! Earum amet quam a?"
multiline
mb-2
/>
<c-input-text v-model:value="valueLong" multiline autosize mb-2 rows="5" />
<c-input-text
value="Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, est modi iusto repellendus fuga accusantium atque at magnam aliquam eum explicabo vero quia, nobis quasi quis! Earum amet quam a?"
multiline
clearable
/>
</template>
<script lang="ts" setup>
import { useValidation } from '@/composable/validation';
const value = ref('value');
const valueLong = ref(
'Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum, est modi iusto repellendus fuga accusantium atque at magnam aliquam eum explicabo vero quia, nobis quasi quis! Earum amet quam a?',
);
const validationRules = [{ message: 'Length must be > 10', validator: (value: string) => value.length > 10 }];
const validation = useValidation({
source: value,
rules: validationRules,
});
</script>

View file

@ -1,7 +1,8 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import { shallowMount, mount } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import _ from 'lodash';
import { useValidation } from '@/composable/validation';
import CInputText from './c-input-text.vue';
describe('CInputText', () => {
@ -71,10 +72,28 @@ describe('CInputText', () => {
it('renders a feedback message for invalid rules', async () => {
const wrapper = shallowMount(CInputText, {
props: { rules: [{ validator: () => false, message: 'Message' }] },
props: { validationRules: [{ validator: () => false, message: 'Message' }] },
});
expect(wrapper.get('.feedback').text()).to.equal('Message');
const feedback = wrapper.find('.feedback');
expect(feedback.exists()).to.equal(true);
expect(feedback.text()).to.equal('Message');
});
it('if the value become valid according to rules, the feedback disappear', async () => {
const wrapper = shallowMount(CInputText, {
props: {
validationRules: [{ validator: (value: string) => value === 'Hello', message: 'Value should be Hello' }],
},
});
const feedback = wrapper.find('.feedback');
expect(feedback.exists()).to.equal(true);
expect(feedback.text()).to.equal('Value should be Hello');
await wrapper.setProps({ value: 'Hello' });
expect(wrapper.find('.feedback').exists()).to.equal(false);
});
it('feedback does not render for valid rules', async () => {
@ -84,4 +103,58 @@ describe('CInputText', () => {
expect(wrapper.find('.feedback').exists()).to.equal(false);
});
it('renders a feedback message for invalid custom validation wrapper', async () => {
const wrapper = shallowMount(CInputText, {
props: {
validation: useValidation({ source: ref(), rules: [{ validator: () => false, message: 'Message' }] }),
},
});
const feedback = wrapper.find('.feedback');
expect(feedback.exists()).to.equal(true);
expect(feedback.text()).to.equal('Message');
});
it('feedback does not render for valid custom validation wrapper', async () => {
const wrapper = shallowMount(CInputText, {
props: {
validation: useValidation({ source: ref(), rules: [{ validator: () => true, message: 'Message' }] }),
},
});
expect(wrapper.find('.feedback').exists()).to.equal(false);
});
it('if the value become valid according to the custom validation wrapper, the feedback disappear', async () => {
const source = ref('');
const wrapper = shallowMount(CInputText, {
props: {
validation: useValidation({
source,
rules: [{ validator: (value: string) => value === 'Hello', message: 'Value should be Hello' }],
}),
},
});
const feedback = wrapper.find('.feedback');
expect(feedback.exists()).to.equal(true);
expect(feedback.text()).to.equal('Value should be Hello');
source.value = 'Hello';
await wrapper.vm.$nextTick();
expect(wrapper.find('.feedback').exists()).to.equal(false);
});
it('[prop:testId] renders a test id on the input', async () => {
const wrapper = mount(CInputText, {
props: {
testId: 'TEST',
},
});
expect(wrapper.get('input').attributes('data-test-id')).to.equal('TEST');
});
});

View file

@ -1,32 +1,60 @@
<template>
<div class="c-input-text" :class="{ disabled, error: !validation.isValid, 'label-left': labelPosition === 'left' }">
<div
class="c-input-text"
:class="{ disabled, error: !validation.isValid, 'label-left': labelPosition === 'left', multiline }"
>
<label v-if="label" :for="id" class="label"> {{ label }} </label>
<div class="input-wrapper">
<slot name="prefix" />
<div class="feedback-wrapper">
<div ref="inputWrapperRef" class="input-wrapper">
<slot name="prefix" />
<input
:id="id"
v-model="value"
type="text"
class="input"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:data-test-id="testId"
:autocapitalize="autocapitalize ?? (rawText ? 'off' : undefined)"
:autocomplete="autocomplete ?? (rawText ? 'off' : undefined)"
:autocorrect="autocorrect ?? (rawText ? 'off' : undefined)"
:spellcheck="spellcheck ?? (rawText ? false : undefined)"
/>
<textarea
v-if="multiline"
:id="id"
ref="textareaRef"
v-model="value"
class="input"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:data-test-id="testId"
:autocapitalize="autocapitalize ?? (rawText ? 'off' : undefined)"
:autocomplete="autocomplete ?? (rawText ? 'off' : undefined)"
:autocorrect="autocorrect ?? (rawText ? 'off' : undefined)"
:spellcheck="spellcheck ?? (rawText ? false : undefined)"
:rows="rows"
/>
<c-button v-if="clearable && value" variant="text" circle size="small" @click="value = ''">
<icon-mdi-close />
</c-button>
<slot name="suffix" />
<input
v-else
:id="id"
v-model="value"
:type="htmlInputType"
class="input"
size="1"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:data-test-id="testId"
:autocapitalize="autocapitalize ?? (rawText ? 'off' : undefined)"
:autocomplete="autocomplete ?? (rawText ? 'off' : undefined)"
:autocorrect="autocorrect ?? (rawText ? 'off' : undefined)"
:spellcheck="spellcheck ?? (rawText ? false : undefined)"
/>
<c-button v-if="clearable && value" variant="text" circle size="small" @click="value = ''">
<icon-mdi-close />
</c-button>
<c-button v-if="type === 'password'" variant="text" circle size="small" @click="showPassword = !showPassword">
<icon-mdi-eye v-if="!showPassword" />
<icon-mdi-eye-off v-if="showPassword" />
</c-button>
<slot name="suffix" />
</div>
<span v-if="!validation.isValid" class="feedback"> {{ validation.message }} </span>
</div>
<span v-if="!validation.isValid" class="feedback"> {{ validation.message }} </span>
</div>
</template>
@ -45,6 +73,7 @@ const props = withDefaults(
readonly?: boolean;
disabled?: boolean;
validationRules?: UseValidationRule<string>[];
validation?: ReturnType<typeof useValidation>;
labelPosition?: 'top' | 'left';
labelWidth?: string;
labelAlign?: 'left' | 'right';
@ -55,6 +84,10 @@ const props = withDefaults(
autocorrect?: 'on' | 'off' | string;
spellcheck?: 'true' | 'false' | boolean;
rawText?: boolean;
type?: 'text' | 'password';
multiline?: boolean;
rows?: number | string;
autosize?: boolean;
}>(),
{
value: '',
@ -64,6 +97,7 @@ const props = withDefaults(
readonly: false,
disabled: false,
validationRules: () => [],
validation: undefined,
labelPosition: 'top',
labelWidth: 'auto',
labelAlign: 'left',
@ -74,20 +108,58 @@ const props = withDefaults(
autocorrect: undefined,
spellcheck: undefined,
rawText: false,
type: 'text',
multiline: false,
rows: 3,
autosize: false,
},
);
const emit = defineEmits(['update:value']);
const value = useVModel(props, 'value', emit);
const showPassword = ref(false);
const { id, placeholder, label, validationRules, labelPosition, labelWidth, labelAlign } = toRefs(props);
const { id, placeholder, label, validationRules, labelPosition, labelWidth, labelAlign, autosize } = toRefs(props);
const validation = useValidation({
rules: validationRules,
source: value,
});
const validation =
props.validation ??
useValidation({
rules: validationRules,
source: value,
});
const theme = useTheme();
const appTheme = useAppTheme();
const textareaRef = ref<HTMLTextAreaElement>();
const inputWrapperRef = ref<HTMLElement>();
watch(
value,
() => {
if (props.multiline && autosize.value) {
resizeTextarea();
}
},
{ immediate: true },
);
function resizeTextarea() {
if (!textareaRef.value || !inputWrapperRef.value) {
return;
}
const { scrollHeight } = textareaRef.value;
inputWrapperRef.value.style.height = `${scrollHeight + 2}px`;
}
const htmlInputType = computed(() => {
if (props.type === 'password' && !showPassword.value) {
return 'password';
}
return 'text';
});
</script>
<style lang="less" scoped>
@ -114,29 +186,55 @@ const appTheme = useAppTheme();
}
}
& > .feedback {
& .feedback {
color: v-bind('appTheme.error.color');
}
}
& > .label {
flex-shrink: 0;
margin-bottom: 5px;
flex: 0 0 v-bind('labelWidth');
text-align: v-bind('labelAlign');
padding-right: 10px;
padding-right: 12px;
}
.input-wrapper {
.feedback-wrapper {
flex: 1 1 0;
min-width: 0;
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
background-color: v-bind('theme.backgroundColor');
color: transparent;
border: 1px solid v-bind('theme.borderColor');
border-radius: 4px;
padding: 0 4px 0 12px;
transition: border-color 0.2s ease-in-out;
.multiline& {
resize: vertical;
overflow: hidden;
& > textarea {
height: 100%;
resize: none;
word-break: break-word;
white-space: pre-wrap;
overflow-wrap: break-word;
border: none;
outline: none;
font-family: inherit;
font-size: inherit;
color: v-bind('appTheme.text.baseColor');
&::placeholder {
color: v-bind('appTheme.text.mutedColor');
}
}
}
& > .input {
flex: 1 1 0;
@ -144,7 +242,6 @@ const appTheme = useAppTheme();
padding: 8px 0;
outline: none;
transition: border-color 0.2s ease-in-out;
background-color: transparent;
background-image: none;
-webkit-box-shadow: none;
@ -159,12 +256,13 @@ const appTheme = useAppTheme();
}
}
&:hover,
&:focus {
&:hover {
border-color: v-bind('appTheme.primary.color');
}
&:focus {
&:focus-within {
border-color: v-bind('appTheme.primary.color');
background-color: v-bind('theme.focus.backgroundColor');
}
}
@ -173,11 +271,11 @@ const appTheme = useAppTheme();
border-color: v-bind('appTheme.error.color');
&:hover,
&:focus {
&:focus-within {
border-color: v-bind('appTheme.error.color');
}
&:focus {
&:focus-within {
background-color: v-bind('appTheme.error.color + 22');
}
}
@ -186,7 +284,7 @@ const appTheme = useAppTheme();
opacity: 0.5;
&:hover,
&:focus {
&:focus-within {
border-color: v-bind('theme.borderColor');
}

View file

@ -21,6 +21,7 @@ export const { themes: appThemes, useTheme: useAppTheme } = defineThemes({
color: '#f59e0b',
colorHover: '#f59e0b',
colorPressed: '#f59e0b',
colorFaded: '#f59e0b2f',
},
success: {
color: '#18a058',
@ -55,6 +56,7 @@ export const { themes: appThemes, useTheme: useAppTheme } = defineThemes({
color: '#f59e0b',
colorHover: '#f59e0b',
colorPressed: '#f59e0b',
colorFaded: '#f59e0b2f',
},
success: {
color: '#18a058',