Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions src/components/SelectInput.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,22 @@ export const Standard: Story = {
};

export const Disabled: Story = {
args: {
name: 'disabled',
disabled: true,
render: () => ({
components: { SelectInput },
template: `
<select-input name="select-disabled" disabled />
`,
}),
parameters: {
docs: {
source: { code: '<select-input name="select-disabled" :options="[...]" disabled />' },
},
},
};

export const Required: Story = {
args: {
name: 'required',
required: true,
modelValue: '',
help: 'Choose wisely.',
options: [
Expand All @@ -74,7 +80,7 @@ export const Required: Story = {
},
template: `
<div>
<select-input v-bind="args" ref="input" />
<select-input v-bind="args" ref="input" required />
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;margin-top: 0.5rem;">
<button @click="triggerInvalid">
Trigger Invalid State
Expand Down Expand Up @@ -104,6 +110,19 @@ export const Autofocus: Story = {
{ label: 'Rost', value: 'rost' },
{ label: 'Sylens', value: 'sylens' },
],
autofocus: true,
},
render: (args) => ({
components: { SelectInput },
setup() {
return { args };
},
template: `
<select-input v-bind="args" autofocus />
`,
}),
parameters: {
docs: {
source: { code: '<select-input name="select-autofocus" :options="[...]" disabled />' },
},
},
};
74 changes: 47 additions & 27 deletions src/components/SelectInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,47 @@
/**
* @note The default slot is deprecated and will be removed in future releases
*/
import { ref } from 'vue';
import { type SelectOption } from '@/models';
import { ref, useAttrs } from 'vue';
import type { ElementEvent, SelectOption } from '@/models';
import ErrorIcon from '@/foundation/ErrorIcon.vue';
import CaretDownIcon from '@/foundation/CaretDownIcon.vue';

const attrs = useAttrs();
const model = defineModel<number | string>();
const isInvalid = ref(false);
const isDirty = ref(false);
const isRequired = Object.hasOwn(attrs, 'required') && attrs.required !== false;
const inputRef = ref<HTMLSelectElement>(null);
const validationMessage = ref('');

const emit = defineEmits(['submit', 'blur']);

// component properties
interface Props {
name: string;
label?: string;
options: SelectOption<number | string>[];
help?: string;
required?: boolean;
autofocus?: boolean;
disabled?: boolean;
error?: string;
dataTestid?: string;
}
withDefaults(defineProps<Props>(), {
label: null,
required: false,
autofocus: false,
disabled: false,
dataTestid: 'select-input',
});

const model = defineModel<number | string>();
const isInvalid = ref(false);
const isDirty = ref(false);
const validationMessage = ref('');
defineOptions({ inheritAttrs: false });

/**
* Forwards focus intent to the select element.
* Unlike HTMLElement.focus() this does not take any parameters.
*/
const focus = () => {
if (!inputRef.value) {
return;
}
inputRef.value.focus();
};

/**
* Resets the component's internal validation state and clears the input value.
Expand All @@ -42,44 +55,49 @@ const reset = () => {
validationMessage.value = '';
};

const onInvalid = (evt: Event) => {
const onInvalid = (evt: ElementEvent<HTMLSelectElement>) => {
isInvalid.value = true;
validationMessage.value = (evt.target as HTMLSelectElement).validationMessage;
validationMessage.value = evt.target.validationMessage;
};
const onInput = (evt: InputEvent) => {
const onInput = () => {
isDirty.value = true;
isInvalid.value = false;
validationMessage.value = '';
inputRef.value.setCustomValidity('');
};

// Revalidate on input change
if ((evt.target as HTMLSelectElement).checkValidity()) {
const onBlur = (evt: ElementEvent<HTMLSelectElement>) => {
if (isDirty.value && evt.target.checkValidity()) {
isInvalid.value = false;
validationMessage.value = '';
}

emit('blur');
};

defineEmits(['submit']);
defineExpose({ reset });
defineExpose({ focus, reset });
</script>

<template>
<label class="wrapper" :for="name">
<span v-if="label || $slots.default" class="label">
<template v-if="label">{{ label }}</template>
<slot v-else />
<span v-if="required && (model === null || model === '')" class="required">*</span>
<span v-if="isRequired && (model === null || model === '')" class="required">*</span>
</span>
<div class="tbpro-select-container">
<select
class="tbpro-select"
:class="{ dirty: isDirty, invalid: isInvalid }"
v-bind="attrs"
v-model="model"
class="tbpro-select"
:class="{ dirty: isDirty, error: error }"
:id="name"
:name="name"
:required="required"
:autofocus="autofocus"
:disabled="disabled"
@invalid="onInvalid"
@input="onInput"
@blur="onBlur"
:data-testid="dataTestid"
ref="inputRef"
>
<option v-for="option in options" :value="option.value" :key="option.value">
{{ option.label }}
Expand Down Expand Up @@ -180,7 +198,7 @@ defineExpose({ reset });
color: var(--colour-ti-muted);
}

&:hover {
&:hover:not(:disabled) {
border-color: var(--colour-neutral-border-intense);
}

Expand All @@ -198,7 +216,9 @@ defineExpose({ reset });
cursor: not-allowed;
}

&.invalid {
&.invalid,
&:has(.dirty:invalid):not(:focus-within),
&:has(.error):not(:focus-within) {
border-color: var(--colour-ti-critical);
}
}
Expand Down
33 changes: 33 additions & 0 deletions src/components/TextArea.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,39 @@ export const Standard: Story = {
},
};

export const Disabled: Story = {
render: () => ({
components: { TextArea },
template: `
<text-area name="disabled-input" label="Full Name" placeholder="e.g. John Doe" disabled />
`,
}),
parameters: {
docs: {
source: {
code: `<text-area name="disabled-input" label="Full Name" placeholder="e.g. John Doe" disabled />`,
},
},
},
};


export const ReadOnly: Story = {
render: () => ({
components: { TextArea },
template: `
<text-area name="readonly-input" label="Preferred Name" modelValue="Frank, Son Of Frank" readonly />
`,
}),
parameters: {
docs: {
source: {
code: `<text-area name="disabled-input" label="Preferred Name" modelValue="Frank, Son Of Frank" readonly />`,
},
},
},
};

export const Required: Story = {
decorators: [
() => ({
Expand Down
59 changes: 46 additions & 13 deletions src/components/TextArea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@
*/
import { computed, ref, useAttrs } from 'vue';
import { t } from '@/composable/i18n';
import ErrorIcon from '@/foundation/ErrorIcon.vue';
import { useTextareaAutosize } from '@vueuse/core';
import type { ElementEvent } from '@/models';
import ErrorIcon from '@/foundation/ErrorIcon.vue';

const attrs = useAttrs();
const model = defineModel<string>();
const isInvalid = ref(false);
const validationMessage = ref('');
const isDirty = ref(false);
const model = defineModel<string>();
const isRequired = Object.hasOwn(attrs, 'required');

const { textarea } = useTextareaAutosize({
input: model,
styleProp: 'minHeight', // Provides support for the rows attribute
});

const charCount = computed(() => model.value?.length ?? 0);
const attrs = useAttrs();
const isRequired = Object.hasOwn(attrs, 'required');

/**
* Forwards focus intent to the text input element.
Expand All @@ -38,39 +41,56 @@ const reset = () => {
model.value = '';
isInvalid.value = false;
isDirty.value = false;
validationMessage.value = '';
};

// component properties
interface Props {
name: string;
label?: string;
help?: string;
error?: string;
smallText?: boolean;
maxLength?: number | string;
dataTestid?: string;
}
const props = withDefaults(defineProps<Props>(), {
label: null,
help: null,
error: null,
prefix: null,
smallText: false,
maxLength: null,
dataTestid: 'text-area',
});

const emit = defineEmits(['submit', 'blur']);
defineExpose({ focus, reset });

const onInvalid = () => {
const onInvalid = (evt: ElementEvent<HTMLTextAreaElement>) => {
isInvalid.value = true;
isDirty.value = true;
validationMessage.value = evt.target.validationMessage;
};

/**
* On any change we mark the element as dirty
* this is so we can delay :invalid until
* the user does something worth invalidating
*/
const onChange = () => {
isDirty.value = true;
isInvalid.value = false;
validationMessage.value = '';
textarea.value.setCustomValidity('');
};

const onBlur = (evt: ElementEvent<HTMLTextAreaElement>) => {
if (isDirty.value && evt.target.checkValidity()) {
isInvalid.value = false;
validationMessage.value = '';
}

emit('blur');
};
</script>

Expand All @@ -83,25 +103,38 @@ const onChange = () => {
</span>
<span class="tbpro-textarea" :class="{ 'small-text': props.smallText }">
<textarea
class="tbpro-textarea-element"
ref="textarea"
v-bind="attrs"
v-model="model"
:class="{ dirty: isDirty }"
class="tbpro-textarea-element"
:class="{
dirty: isDirty,
error: error !== null,
}"
:id="name"
:name="name"
:maxLength="maxLength"
:maxlength="maxLength"
:data-testid="dataTestid"
@invalid="onInvalid"
@change="onChange"
@blur="onBlur"
ref="textarea"
/>
<span v-if="maxLength !== null" class="character-count" aria-live="polite" :aria-label="t('textInput.maxLengthAlt', {currentCount: charCount, maxCount: maxLength})"> {{ charCount }}/{{ maxLength }} </span>
<span
v-if="maxLength !== null"
class="character-count"
aria-live="polite"
:aria-label="t('textInput.maxLengthAlt', {currentCount: charCount, maxCount: maxLength})"
> {{ charCount }}/{{ maxLength }} </span>
</span>
<span v-if="isInvalid" class="help-label invalid">
<error-icon />
{{ t('textArea.invalidInput') }}
{{ validationMessage }}
</span>
<span v-else-if="error" class="help-label invalid">
<error-icon />
{{ error }}
</span>
<span v-else-if="help" class="help-label">
<span v-if="help" class="help-label">
{{ help }}
</span>
</label>
Expand Down
Loading
Loading