Skip to content
Snippets Groups Projects
Commit 1b359aab authored by Peter Hegman's avatar Peter Hegman :two:
Browse files

feat(GlFormTextarea): add support for character count

Displays characters remaining and characters over limit
parent 6a0b74a7
No related branches found
No related tags found
1 merge request!4097feat(GlFormTextarea): add support for character count
import Vue from 'vue';
import { h } from '@vue/compat';
import { useArgs } from '@storybook/preview-api';
import 'iframe-resizer/js/iframeResizer.contentWindow.min.js';
import setConfigs from '../src/config';
......@@ -11,8 +12,11 @@ import '../src/scss/storybook.scss';
import '../src/scss/tailwind.css';
let decorators = [
(story) => ({
components: { story },
(story, context) => {
const [_, updateArgs] = useArgs();
return story({ ...context, updateArgs });
},
() => ({
template: '<story />',
mounted() {
this.$nextTick().then(() => {
......
import { mount } from '@vue/test-utils';
import lodashDebounce from 'lodash/debounce';
import GlFormTextarea from './form_textarea.vue';
jest.mock('lodash/debounce', () => jest.fn((fn) => fn));
const modelEvent = GlFormTextarea.model.event;
const newValue = 'foo';
......@@ -10,6 +13,25 @@ describe('GlFormTextArea', () => {
const createComponent = (propsData = {}) => {
wrapper = mount(GlFormTextarea, {
propsData,
scopedSlots: {
'character-count-text': function characterCountText({ count }) {
return count === 1 ? `${count} character remaining` : `${count} characters remaining`;
},
'character-count-over-limit-text': function characterCountOverLimitText({ count }) {
return count === 1 ? `${count} character over limit` : `${count} characters over limit`;
},
},
});
};
const findTextarea = () => wrapper.find('textarea');
const itUpdatesDebouncedScreenReaderText = (expectedText) => {
it('updates debounced screen reader text', () => {
expect(lodashDebounce).toHaveBeenCalledWith(expect.any(Function), 1000);
expect(wrapper.find('[data-testid="character-count-text-sr-only"]').text()).toBe(
expectedText
);
});
};
......@@ -20,7 +42,7 @@ describe('GlFormTextArea', () => {
});
it(`sets the textarea's value`, () => {
expect(wrapper.element.value).toBe('initial');
expect(findTextarea().element.value).toBe('initial');
});
describe('when the value prop changes', () => {
......@@ -30,7 +52,7 @@ describe('GlFormTextArea', () => {
});
it(`updates the textarea's value`, () => {
expect(wrapper.element.value).toBe(newValue);
expect(findTextarea().element.value).toBe(newValue);
});
});
});
......@@ -39,7 +61,7 @@ describe('GlFormTextArea', () => {
beforeEach(() => {
createComponent();
wrapper.setValue(newValue);
findTextarea().setValue(newValue);
});
it('synchronously emits update event', () => {
......@@ -59,7 +81,7 @@ describe('GlFormTextArea', () => {
createComponent({ debounce });
wrapper.setValue(newValue);
findTextarea().setValue(newValue);
});
it('synchronously emits an update event', () => {
......@@ -82,7 +104,7 @@ describe('GlFormTextArea', () => {
beforeEach(() => {
createComponent({ lazy: true });
wrapper.setValue(newValue);
findTextarea().setValue(newValue);
});
it('synchronously emits an update event', () => {
......@@ -119,4 +141,65 @@ describe('GlFormTextArea', () => {
expect(wrapper.emitted('submit')).toEqual([[]]);
});
});
describe('when `characterCount` prop is set', () => {
const characterCount = 10;
describe('when textarea character count is under the max character count', () => {
const textareaCharacterCount = 5;
const expectedText = `${characterCount - textareaCharacterCount} characters remaining`;
beforeEach(() => {
createComponent({
value: 'a'.repeat(textareaCharacterCount),
characterCount,
});
});
it('displays remaining characters', () => {
expect(wrapper.text()).toContain(expectedText);
});
itUpdatesDebouncedScreenReaderText(expectedText);
});
describe('when textarea character count is over the max character count', () => {
const textareaCharacterCount = 15;
const expectedText = `${textareaCharacterCount - characterCount} characters over limit`;
beforeEach(() => {
createComponent({
value: 'a'.repeat(textareaCharacterCount),
characterCount,
});
});
it('displays number of characters over', () => {
expect(wrapper.text()).toContain(expectedText);
});
itUpdatesDebouncedScreenReaderText(expectedText);
});
describe('when textarea value is updated', () => {
const textareaCharacterCount = 5;
const newTextareaCharacterCount = textareaCharacterCount + 3;
const expectedText = `${characterCount - newTextareaCharacterCount} characters remaining`;
beforeEach(() => {
createComponent({
value: 'a'.repeat(textareaCharacterCount),
characterCount,
});
wrapper.setProps({ value: 'a'.repeat(newTextareaCharacterCount) });
});
it('updates character count text', () => {
expect(wrapper.text()).toContain(expectedText);
});
itUpdatesDebouncedScreenReaderText(expectedText);
});
});
});
......@@ -3,32 +3,57 @@ import readme from './form_textarea.md';
const template = `
<gl-form-textarea
v-model="model"
:value="value"
:placeholder="placeholder"
:rows="5"
:no-resize="noResize"
/>
:character-count="characterCount"
@input="onInput"
>
<template #character-count-text="{ count }">{{ characterCountText(count) }}</template>
<template #character-count-over-limit-text="{ count }">{{ characterCountOverLimitText(count) }}</template>
</gl-form-textarea>
`;
const generateProps = ({
model = 'We take inspiration from other companies, and we always go for the boring solutions. Just like the rest of our work, we continually adjust our values and strive always to make them better. We used to have more values, but it was difficult to remember them all, so we condensed them and gave sub-values and created an acronym. Everyone is welcome to suggest improvements.',
value = 'We take inspiration from other companies, and we always go for the boring solutions. Just like the rest of our work, we continually adjust our values and strive always to make them better. We used to have more values, but it was difficult to remember them all, so we condensed them and gave sub-values and created an acronym. Everyone is welcome to suggest improvements.',
placeholder = 'hello',
noResize = GlFormTextarea.props.noResize.default,
characterCount = null,
} = {}) => ({
model,
value,
placeholder,
noResize,
characterCount,
});
const Template = (args) => ({
const Template = (args, { updateArgs }) => ({
components: { GlFormTextarea },
props: Object.keys(args),
methods: {
onInput(value) {
updateArgs({ ...args, value });
},
characterCountText(count) {
return count === 1 ? `${count} character remaining` : `${count} characters remaining`;
},
characterCountOverLimitText(count) {
return count === 1 ? `${count} character over limit` : `${count} characters over limit`;
},
},
template,
});
export const Default = Template.bind({});
Default.args = generateProps();
export const WithCharacterCount = Template.bind({});
WithCharacterCount.args = generateProps({
value: '',
placeholder: 'hello',
characterCount: 100,
});
export default {
title: 'base/form/form-textarea',
component: GlFormTextarea,
......
<script>
import { BFormTextarea } from 'bootstrap-vue';
import debounce from 'lodash/debounce';
import uniqueId from 'lodash/uniqueId';
const model = {
prop: 'value',
......@@ -31,6 +33,21 @@ export default {
required: false,
default: false,
},
/**
* Max character count for the textarea.
*/
characterCount: {
type: Number,
required: false,
default: null,
},
},
data() {
return {
characterCountId: uniqueId('form-textarea-character-count-'),
remainingCharacterCount: this.initialRemainingCharacterCount(),
remainingCharacterCountSrOnly: this.initialRemainingCharacterCount(),
};
},
computed: {
listeners() {
......@@ -57,6 +74,41 @@ export default {
keypressEvent() {
return this.submitOnEnter ? 'keyup' : null;
},
isCharacterCountOverLimit() {
return this.remainingCharacterCount < 0;
},
characterCountTextClass() {
return this.isCharacterCountOverLimit ? 'gl-text-red-500' : 'gl-text-gray-500';
},
showCharacterCount() {
return this.characterCount !== null;
},
bFormTextareaProps() {
return {
...this.$attrs,
class: 'gl-form-input gl-form-textarea',
noResize: this.noResize,
value: this.value,
};
},
},
watch: {
value(newValue) {
if (!this.showCharacterCount) {
return;
}
this.remainingCharacterCount = this.characterCount - newValue.length;
this.debouncedUpdateRemainingCharacterCountSrOnly(newValue);
},
},
created() {
// Debounce updating the remaining character count for a second so
// screen readers announce the remaining text after the text in the textarea.
this.debouncedUpdateRemainingCharacterCountSrOnly = debounce(
this.updateRemainingCharacterCountSrOnly,
1000
);
},
methods: {
handleKeyPress(e) {
......@@ -64,16 +116,59 @@ export default {
this.$emit('submit');
}
},
updateRemainingCharacterCountSrOnly(newValue) {
this.remainingCharacterCountSrOnly = this.characterCount - newValue.length;
},
initialRemainingCharacterCount() {
return this.characterCount - this.value.length;
},
},
};
</script>
<template>
<div v-if="showCharacterCount">
<b-form-textarea
:aria-describedby="characterCountId"
v-bind="bFormTextareaProps"
v-on="listeners"
@[keypressEvent].native="handleKeyPress"
/>
<small :class="['form-text', characterCountTextClass]" aria-hidden="true">
<!--
@slot Internationalized over character count text. Example: `<template #character-count-over-limit-text="{ count }">{{ n__('%d character over limit', '%d characters over limit', count) }}</template>`
@binding {number} count
-->
<slot
v-if="isCharacterCountOverLimit"
name="character-count-over-limit-text"
:count="Math.abs(remainingCharacterCount)"
></slot>
<!--
@slot Internationalized character count text. Example: `<template #character-count-text="{ count }">{{ n__('%d character remaining', '%d characters remaining', count) }}</template>`
@binding {number} count
-->
<slot v-else name="character-count-text" :count="remainingCharacterCount"></slot>
</small>
<div
:id="characterCountId"
class="gl-sr-only"
aria-live="polite"
data-testid="character-count-text-sr-only"
>
<slot
v-if="isCharacterCountOverLimit"
name="character-count-over-limit-text"
:count="Math.abs(remainingCharacterCount)"
></slot>
<slot v-else name="character-count-text" :count="remainingCharacterCountSrOnly"></slot>
</div>
</div>
<b-form-textarea
class="gl-form-input gl-form-textarea"
:no-resize="noResize"
v-bind="$attrs"
:value="value"
v-else
v-bind="bFormTextareaProps"
v-on="listeners"
@[keypressEvent].native="handleKeyPress"
/>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment