Skip to content

Commit b442f85

Browse files
committed
feat: New TextInput component
1 parent 8c6fe4a commit b442f85

File tree

4 files changed

+666
-0
lines changed

4 files changed

+666
-0
lines changed
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { Meta, Title, Story, Source } from '@storybook/blocks';
2+
import * as NumberInputStories from './NumberInput.stories';
3+
4+
<Meta title="Components/NumberInput" of={NumberInputStories} />
5+
6+
<Title>NumberInput</Title>
7+
8+
```ts
9+
import NumberInput from '@jackdomleo7/vue3-library/components/NumberInput/NumberInput.vue';
10+
```
11+
12+
<Story of={NumberInputStories.Default} />
13+
<br/>
14+
15+
## `v-model`
16+
17+
You can bind any number value to the `v-model` prop. This is a two-way binding, so any changes to the input will update the bound value, and vice versa.
18+
19+
```ts
20+
import { ref } from 'vue';
21+
22+
const value = ref<number>(7);
23+
```
24+
25+
```html
26+
<NumberInput v-model="value" />
27+
```
28+
29+
## Props
30+
31+
### Root Class
32+
33+
- Prop: `rootClass`
34+
- Type: `string`
35+
- Default: `undefined`
36+
37+
Classes to add directly to the root element's `class` attribute.
38+
39+
### Root Style
40+
41+
- Prop: `rootStyle`
42+
- Type: `string`
43+
- Default: `undefined`
44+
45+
Styles to add directly to the root element's `style` attribute.
46+
47+
### ID
48+
49+
- Prop: `id`
50+
- Type: `string`
51+
- Required: `true`
52+
53+
The `id` of the `<input/>` and the `for` of the `<label/>` element. This may also be used in various ARIA attributes.
54+
55+
### Status
56+
57+
- Prop: `status`
58+
- Type: `"error"|"success"`
59+
- Default: `undefined`
60+
61+
The validation status of the input.
62+
63+
<Story of={NumberInputStories.StatusSuccess} />
64+
<br/>
65+
<Story of={NumberInputStories.StatusError} />
66+
67+
### Hidden Label
68+
69+
- Prop: `hiddenLabel`
70+
- Type: `boolean`
71+
- Default: `false`
72+
73+
If you want to hide the label, you will still need to set a label (for accessibility), but you can then set this prop to `true` to hide the label visually.
74+
75+
Only to be used where the context of its use is clear.
76+
77+
<Story of={NumberInputStories.HiddenLabel} />
78+
79+
### Fallthrough Attributes
80+
81+
Any non-prop attributes and any event listeners will fallthrough to the `<input/>` element. This means you can use any native HTML attributes that are valid for an `<input/>` element with the `type` specified. Below are just some examples.
82+
83+
#### Disabled
84+
85+
```html
86+
<NumberInput disabled />
87+
```
88+
89+
<Story of={NumberInputStories.Disabled} />
90+
<br/>
91+
92+
#### Readonly
93+
94+
```html
95+
<NumberInput readonly />
96+
```
97+
98+
<Story of={NumberInputStories.Readonly} />
99+
<br/>
100+
101+
#### Required
102+
103+
```html
104+
<NumberInput required />
105+
```
106+
107+
<Story of={NumberInputStories.Required} />
108+
<br/>
109+
110+
#### Minlength
111+
112+
```html
113+
<NumberInput min="5" />
114+
```
115+
116+
<Story of={NumberInputStories.Min} />
117+
<br/>
118+
119+
#### Maxlength
120+
121+
```html
122+
<NumberInput max="5" />
123+
```
124+
125+
<Story of={NumberInputStories.Max} />
126+
127+
## Slots
128+
129+
### Error
130+
131+
- Slot: `error`
132+
133+
Recommended to use with `status="error"`.
134+
135+
Single error.
136+
137+
<Story of={NumberInputStories.SingleError} />
138+
<br/>
139+
140+
Multiple errors.
141+
142+
<Story of={NumberInputStories.MultipleErrors} />
143+
144+
### Description
145+
146+
- Slot: `description`
147+
148+
<Story of={NumberInputStories.WithDescription} />
149+
150+
## Customisation
151+
152+
You can customise the component using the `rootClass` and `rootStyle` props to apply top-level styles. You can also use the provided CSS variables to alter deep styles.
153+
154+
- `--j-numberinput-height` | **Type:** [`height`](https://developer.mozilla.org/en-US/docs/Web/CSS/height) | **Default:** `2.5rem` | The height of the input element.
155+
156+
<Story of={NumberInputStories.Custom} />
157+
158+
## Behaviour
159+
160+
### Width
161+
162+
This component will take up the full width of the parent component by default. You can use the `rootClass` or `rootStyle` props to set a custom width.
163+
164+
The minimum width is 1.5&times; the `--j-numberinput-height` variable.
165+
166+
## Accessibility
167+
168+
This component uses native HTML behaviour for `<input type="number" />`. This means that it will be keyboard navigable and screen reader friendly out of the box.
169+
170+
`aria-invalid` is added to the `<input />` element when the `status` prop is set. This will be set to `true` when `status="error"` and `false` when `status="success"`.
171+
172+
`aria-describedby` is added to the `<input />` element when the `description` slot is used to associate this content with the input.
173+
174+
`aria-errormessage` is added to the `<input />` element when the `error` slot is used to associate this content with the input.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { describe } from 'vitest';
2+
import { shallowMount } from '@vue/test-utils';
3+
import JNumberInput from './NumberInput.vue';
4+
5+
describe('NumberInput', () => {
6+
it('renders a root class', () => {
7+
// Arrange
8+
const wrapper = shallowMount(JNumberInput, {
9+
props: {
10+
id: 'test-id',
11+
rootClass: 'test-class'
12+
}
13+
});
14+
15+
// Assert
16+
expect(wrapper.classes()).toContain('test-class');
17+
});
18+
19+
it('renders a root style', () => {
20+
// Arrange
21+
const wrapper = shallowMount(JNumberInput, {
22+
props: {
23+
id: 'test-id',
24+
rootStyle: 'width: 50%;'
25+
}
26+
});
27+
28+
// Assert
29+
expect(wrapper.attributes('style')).toContain('width: 50%');
30+
});
31+
32+
it('renders an id', () => {
33+
// Arrange
34+
const wrapper = shallowMount(JNumberInput, {
35+
props: {
36+
id: 'test-id'
37+
}
38+
});
39+
40+
// Assert
41+
expect(wrapper.find('label').attributes('for')).toBe('test-id');
42+
expect(wrapper.find('input').attributes('id')).toBe('test-id');
43+
});
44+
45+
it('renders a number input', () => {
46+
// Arrange
47+
const wrapper = shallowMount(JNumberInput, {
48+
props: {
49+
id: 'test-id'
50+
}
51+
});
52+
53+
// Assert
54+
expect(wrapper.find('input').attributes('type')).toBe('number');
55+
});
56+
57+
describe('status', () => {
58+
it('renders a default status', () => {
59+
// Arrange
60+
const wrapper = shallowMount(JNumberInput, {
61+
props: {
62+
id: 'test-id'
63+
}
64+
});
65+
66+
// Assert
67+
expect(wrapper.find('input').attributes('aria-invalid')).toBeUndefined();
68+
expect(wrapper.find('.j-numberinput__validation-icon').exists()).toBe(false);
69+
});
70+
71+
it('renders an error status', () => {
72+
// Arrange
73+
const wrapper = shallowMount(JNumberInput, {
74+
props: {
75+
id: 'test-id',
76+
status: 'error'
77+
}
78+
});
79+
80+
// Assert
81+
expect(wrapper.find('input').attributes('aria-invalid')).toBe('true');
82+
expect(wrapper.find('.j-numberinput__validation-icon').text()).toBe('✘');
83+
});
84+
85+
it('renders a success status', () => {
86+
// Arrange
87+
const wrapper = shallowMount(JNumberInput, {
88+
props: {
89+
id: 'test-id',
90+
status: 'success'
91+
}
92+
});
93+
94+
// Assert
95+
expect(wrapper.find('input').attributes('aria-invalid')).toBe('false');
96+
expect(wrapper.find('.j-numberinput__validation-icon').text()).toBe('✔');
97+
});
98+
});
99+
100+
it('hides label when prompted', () => {
101+
// Arrange
102+
const wrapper = shallowMount(JNumberInput, {
103+
props: {
104+
id: 'test-id',
105+
hiddenLabel: true
106+
},
107+
slots: {
108+
label: 'Label'
109+
}
110+
});
111+
112+
// Assert
113+
expect(wrapper.find('label').classes()).toContain('sr-only');
114+
});
115+
116+
it('renders a required input', () => {
117+
// Arrange
118+
const wrapper = shallowMount(JNumberInput, {
119+
props: {
120+
id: 'test-id',
121+
},
122+
attrs: {
123+
required: 'true'
124+
}
125+
});
126+
127+
// Assert
128+
expect(wrapper.find('label').text()).toContain('* (required)');
129+
});
130+
131+
it('renders an error message', () => {
132+
// Arrange
133+
const wrapper = shallowMount(JNumberInput, {
134+
props: {
135+
id: 'test-id'
136+
},
137+
slots: {
138+
error: '<p>This is an error message</p>'
139+
}
140+
});
141+
142+
// Assert
143+
expect(wrapper.find('.j-numberinput__error').exists()).toBe(true);
144+
expect(wrapper.find('.j-numberinput__error').html()).toContain('This is an error message');
145+
expect(wrapper.find('input').attributes('aria-errormessage')).toContain('test-id-error');
146+
});
147+
148+
it('renders a description message', () => {
149+
// Arrange
150+
const wrapper = shallowMount(JNumberInput, {
151+
props: {
152+
id: 'test-id'
153+
},
154+
slots: {
155+
description: '<p>This is a description message</p>'
156+
}
157+
});
158+
159+
// Assert
160+
expect(wrapper.find('.j-numberinput__description').exists()).toBe(true);
161+
expect(wrapper.find('.j-numberinput__description').html()).toContain('This is a description message');
162+
expect(wrapper.find('input').attributes('aria-describedby')).toContain('test-id-description');
163+
});
164+
165+
it('emits @update:modelValue on input', async () => {
166+
// Arrange
167+
const wrapper = shallowMount(JNumberInput, {
168+
props: {
169+
id: 'test-id',
170+
modelValue: 5
171+
}
172+
});
173+
174+
// Act
175+
await wrapper.find('input').setValue('7');
176+
177+
// Assert
178+
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([7]);
179+
});
180+
});

0 commit comments

Comments
 (0)