Skip to content

Commit 3821bc0

Browse files
WEBDEV-8393 Add full OTP form (#42)
* Add placeholder option to status indicator * Add basic OTP form * Build out basic story * Tidy up default props handling * Touch up styling and make configurable * Add timeout to ensure code displays before alert * Clear input when new code sending * Tidy up * Add usage notes
1 parent 9e65956 commit 3821bc0

10 files changed

Lines changed: 542 additions & 8 deletions

File tree

demo/story-components/story-prop-settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export class StoryPropsSettings extends LitElement {
147147
this.dispatchEvent(
148148
new CustomEvent('propsApplied', {
149149
detail: {
150-
stringifiedProps: '\n ' + stringifiedProps.join('\n ') + '\n',
150+
stringifiedProps: stringifiedProps.join('\n '),
151151
appliedProps,
152152
},
153153
}),

demo/story-template.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export class StoryTemplate extends LitElement {
4848
/* Whether settings inputs have been slotted in and should be displayed */
4949
@state() private shouldShowPropertySettings: boolean = false;
5050

51+
/* Whether notes have been slotted in and should be displayed */
52+
@state() private shouldShowUsageNotes: boolean = false;
53+
5154
/* Component that has been slotted into the demo, if applicable */
5255
@state() private slottedDemoComponent?: any;
5356

@@ -186,6 +189,13 @@ export class StoryTemplate extends LitElement {
186189
)}
187190
</div>
188191
</div>
192+
${when(this.shouldShowUsageNotes, () => html` <h3>Usage Notes</h3>`)}
193+
<div class="slot-container">
194+
<slot
195+
name="usage-notes"
196+
@slotchange=${this.handleUsageNotesSlotChange}
197+
></slot>
198+
</div>
189199
`;
190200
}
191201

@@ -213,10 +223,14 @@ export class StoryTemplate extends LitElement {
213223

214224
private get exampleUsage(): string {
215225
const defaultProps = this.defaultUsageProps
216-
? '\n ' + this.defaultUsageProps + '\n'
226+
? ' ' + this.defaultUsageProps + '\n'
217227
: '';
218-
const appliedProps = this.stringifiedProps ?? '';
219-
return `<${this.elementTag}${defaultProps}${appliedProps}></${this.elementTag}>`;
228+
const appliedProps = this.stringifiedProps
229+
? ' ' + this.stringifiedProps + '\n'
230+
: '';
231+
const hasProps = !!defaultProps || !!appliedProps;
232+
233+
return `<${this.elementTag}${hasProps ? '\n' : ''}${defaultProps}${appliedProps}></${this.elementTag}>`;
220234
}
221235

222236
private get cssCode(): string {
@@ -236,6 +250,12 @@ export class StoryTemplate extends LitElement {
236250
this.shouldShowPropertySettings = slottedChildren.length > 0;
237251
}
238252

253+
/* Toggles visibility of section heading depending on whether notes have been slotted in */
254+
private handleUsageNotesSlotChange(e: Event): void {
255+
const slottedChildren = (e.target as HTMLSlotElement).assignedElements();
256+
this.shouldShowUsageNotes = slottedChildren.length > 0;
257+
}
258+
239259
/* Detects and stores a reference to slotted demo component */
240260
private handleDemoComponentSlotted(e: Event): void {
241261
const slottedComponent = (
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { html, LitElement } from 'lit';
2+
import { customElement } from 'lit/decorators.js';
3+
4+
import type { PropInputSettings } from '@demo/story-components/story-prop-settings';
5+
import type { StyleInputSettings } from '@demo/story-components/story-styles-settings';
6+
import type { IAOTPForm } from './ia-otp-form';
7+
8+
import './ia-otp-form';
9+
import '@demo/story-template';
10+
11+
const styleInputSettings: StyleInputSettings[] = [
12+
{
13+
label: 'Text color',
14+
cssVariable: '--ia-theme-primary-text-color',
15+
defaultValue: '#2c2c2c',
16+
inputType: 'color',
17+
},
18+
{
19+
label: 'Input font size',
20+
cssVariable: '--ia-theme-font-size-lg',
21+
defaultValue: '2.25rem',
22+
inputType: 'text',
23+
},
24+
{
25+
label: 'Link and error font size',
26+
cssVariable: '--ia-theme-font-size-standard',
27+
defaultValue: '0.875rem',
28+
inputType: 'text',
29+
},
30+
{
31+
label: 'Link font color',
32+
cssVariable: '--ia-theme-link-color',
33+
defaultValue: '#4b64ff',
34+
inputType: 'color',
35+
},
36+
{
37+
label: 'Error message/indicator color',
38+
cssVariable: '--ia-theme-color-danger',
39+
defaultValue: '#e51c23',
40+
inputType: 'color',
41+
},
42+
{
43+
label: 'Success indicator color',
44+
cssVariable: '--ia-theme-color-success',
45+
defaultValue: '#31a481',
46+
inputType: 'color',
47+
},
48+
];
49+
50+
const propInputSettings: PropInputSettings<IAOTPForm>[] = [
51+
{
52+
label: 'Validation Status',
53+
propertyName: 'validationStatus',
54+
defaultValue: 'ready',
55+
inputType: 'radio',
56+
radioOptions: ['ready', 'loading', 'success', 'error'],
57+
},
58+
{
59+
label: 'New code sending in progress',
60+
propertyName: 'newCodeSending',
61+
defaultValue: false,
62+
inputType: 'radio',
63+
radioOptions: [true, false],
64+
},
65+
{
66+
label: 'Numeric only',
67+
propertyName: 'numericOnly',
68+
defaultValue: true,
69+
inputType: 'radio',
70+
radioOptions: [true, false],
71+
},
72+
{
73+
label: 'Number of passcode characters',
74+
propertyName: 'numPasscodeChars',
75+
defaultValue: 6,
76+
inputType: 'number',
77+
},
78+
];
79+
80+
@customElement('ia-otp-form-story')
81+
export class IAOTPFormStory extends LitElement {
82+
render() {
83+
return html`
84+
<story-template
85+
elementTag="ia-otp-form"
86+
elementClassName="IAOTPForm"
87+
.defaultUsageProps=${"@codeSubmitted=\${(e: CustomEvent) => {setTimeout(() => alert('Code submitted: ' + e.detail), 250);}} \n @newCodeRequested=\${() => alert('New code requested')}"}
88+
.styleInputData=${{ settings: styleInputSettings }}
89+
.propInputData=${{ settings: propInputSettings }}
90+
>
91+
<ia-otp-form
92+
slot="demo"
93+
@codeSubmitted=${(e: CustomEvent) => {
94+
setTimeout(() => alert('Code submitted: ' + e.detail), 250);
95+
}}
96+
@newCodeRequested=${() => alert('New code requested')}
97+
></ia-otp-form>
98+
<div slot="usage-notes">
99+
For a typical One Time Passcode (OTP) use case, the component can be
100+
used like so:
101+
<ul>
102+
<li>
103+
The parent component sends the user a code, then displays the
104+
<code>ia-otp-form</code> component for code entry
105+
</li>
106+
<li>
107+
Once the user finishes entering a code, the component emits a
108+
<code>codeSubmitted</code> event with the code stored in the event
109+
<code>detail</code>
110+
</li>
111+
<li>
112+
The parent component sends that code to be verified and sets the
113+
<code>validationStatus</code> to <code>loading</code>
114+
</li>
115+
<li>
116+
Depending on the result, the parent then sets the
117+
<code>validationStatus</code> to <code>success</code> or
118+
<code>error</code> to display a success or error state
119+
</li>
120+
<li>
121+
If the user requests a new code from within the component, it will
122+
emit a <code>newCodeRequested</code> event and clear the inputs,
123+
and the parent can set the <code>newCodeSending</code> property to
124+
<code>true</code> while the code is being sent, then back to
125+
<code>false</code> when it is ready for entry
126+
</li>
127+
</ul>
128+
</div>
129+
</story-template>
130+
`;
131+
}
132+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { fixture, oneEvent } from '@open-wc/testing-helpers';
2+
import { describe, expect, test } from 'vitest';
3+
import { html } from 'lit';
4+
5+
import type { IAOTPInput } from '@src/elements/ia-otp-input/ia-otp-input';
6+
import type { IAStatusIndicator } from '@src/elements/ia-status-indicator/ia-status-indicator';
7+
import type { IAOTPForm } from './ia-otp-form';
8+
9+
import './ia-otp-form';
10+
11+
describe('IA OTP form', () => {
12+
test('renders an OTP input by default', async () => {
13+
const el = await fixture<IAOTPForm>(html`<ia-otp-form></ia-otp-form>`);
14+
15+
const OTPInput = el.shadowRoot?.querySelector('ia-otp-input');
16+
expect(OTPInput).to.exist;
17+
});
18+
19+
test('requests new code on "send me another code" button click', async () => {
20+
const el = await fixture<IAOTPForm>(html`<ia-otp-form></ia-otp-form>`);
21+
22+
const eventPromise = oneEvent(el, 'newCodeRequested');
23+
24+
const newCodeBtn = el.shadowRoot?.querySelector(
25+
'.new-code-btn',
26+
) as HTMLButtonElement;
27+
newCodeBtn?.click();
28+
29+
const codeRequestEvent = await eventPromise;
30+
expect(codeRequestEvent).to.exist;
31+
});
32+
33+
test('clears current input on "send me another code" button click', async () => {
34+
const el = await fixture<IAOTPForm>(html`<ia-otp-form></ia-otp-form>`);
35+
36+
const input = el.shadowRoot?.querySelector('ia-otp-input') as IAOTPInput;
37+
input.prefillValue = '12345';
38+
39+
const newCodeBtn = el.shadowRoot?.querySelector(
40+
'.new-code-btn',
41+
) as HTMLButtonElement;
42+
newCodeBtn?.click();
43+
expect(input.prefillValue).to.equal('');
44+
});
45+
46+
test('can switch out button for loading message if new code sending', async () => {
47+
const el = await fixture<IAOTPForm>(
48+
html`<ia-otp-form .newCodeSending=${true}></ia-otp-form>`,
49+
);
50+
51+
const newCodeBtn = el.shadowRoot?.querySelector(
52+
'.new-code-btn',
53+
) as HTMLButtonElement;
54+
const newCodeMsg = el.shadowRoot?.querySelector('.new-code-msg');
55+
expect(newCodeBtn).not.to.exist;
56+
expect(newCodeMsg).to.exist;
57+
});
58+
59+
test('does not include email by default in "send me another code" request', async () => {
60+
const el = await fixture<IAOTPForm>(html`<ia-otp-form></ia-otp-form>`);
61+
62+
const eventPromise = oneEvent(el, 'newCodeRequested');
63+
64+
const newCodeBtn = el.shadowRoot?.querySelector(
65+
'.new-code-btn',
66+
) as HTMLButtonElement;
67+
newCodeBtn?.click();
68+
69+
const codeRequestEvent = await eventPromise;
70+
expect(codeRequestEvent).to.exist;
71+
expect(codeRequestEvent.detail).to.be.null;
72+
});
73+
74+
test('disables OTP input if validation in progress', async () => {
75+
const el = await fixture<IAOTPForm>(
76+
html`<ia-otp-form .validationStatus=${'loading'}></ia-otp-form>`,
77+
);
78+
79+
const input = el.shadowRoot?.querySelector('ia-otp-input') as IAOTPInput;
80+
expect(input.disabled).to.be.true;
81+
});
82+
83+
test('disables new code button if validation in progress', async () => {
84+
const el = await fixture<IAOTPForm>(
85+
html`<ia-otp-form .validationStatus=${'loading'}></ia-otp-form>`,
86+
);
87+
88+
const newCodeBtn = el.shadowRoot?.querySelector(
89+
'.new-code-btn',
90+
) as HTMLButtonElement;
91+
expect(newCodeBtn.disabled).to.be.true;
92+
});
93+
94+
test('displays a loading indicator next to the input if validation in progress', async () => {
95+
const el = await fixture<IAOTPForm>(
96+
html`<ia-otp-form .validationStatus=${'loading'}></ia-otp-form>`,
97+
);
98+
99+
const statusIndicator = el.shadowRoot?.querySelector(
100+
'ia-status-indicator',
101+
) as IAStatusIndicator;
102+
expect(statusIndicator.mode).to.equal('loading');
103+
});
104+
105+
test('disables OTP input if validation successful', async () => {
106+
const el = await fixture<IAOTPForm>(
107+
html`<ia-otp-form .validationStatus=${'success'}></ia-otp-form>`,
108+
);
109+
110+
const input = el.shadowRoot?.querySelector('ia-otp-input') as IAOTPInput;
111+
expect(input.disabled).to.be.true;
112+
});
113+
114+
test('displays a success icon next to the input if validation successful', async () => {
115+
const el = await fixture<IAOTPForm>(
116+
html`<ia-otp-form .validationStatus=${'success'}></ia-otp-form>`,
117+
);
118+
119+
const statusIndicator = el.shadowRoot?.querySelector(
120+
'ia-status-indicator',
121+
) as IAStatusIndicator;
122+
expect(statusIndicator.mode).to.equal('success');
123+
});
124+
125+
test('does not disable OTP input if validation not successful', async () => {
126+
const el = await fixture<IAOTPForm>(
127+
html`<ia-otp-form .validationStatus=${'error'}></ia-otp-form>`,
128+
);
129+
130+
const input = el.shadowRoot?.querySelector('ia-otp-input') as IAOTPInput;
131+
expect(input.disabled).to.be.false;
132+
});
133+
134+
test('clears OTP input if validation not successful', async () => {
135+
const el = await fixture<IAOTPForm>(html`<ia-otp-form></ia-otp-form>`);
136+
const input = el.shadowRoot?.querySelector('ia-otp-input') as IAOTPInput;
137+
input.prefillValue = '12345';
138+
el.validationStatus = 'error';
139+
140+
await el.updateComplete;
141+
142+
expect(input.prefillValue).to.equal(undefined);
143+
});
144+
145+
test('displays an error icon next to the input if validation not successful', async () => {
146+
const el = await fixture<IAOTPForm>(
147+
html`<ia-otp-form .validationStatus=${'error'}></ia-otp-form>`,
148+
);
149+
150+
const statusIndicator = el.shadowRoot?.querySelector(
151+
'ia-status-indicator',
152+
) as IAStatusIndicator;
153+
expect(statusIndicator.mode).to.equal('error');
154+
});
155+
156+
test('displays an error message after the input if validation not successful', async () => {
157+
const el = await fixture<IAOTPForm>(
158+
html`<ia-otp-form .validationStatus=${'error'}></ia-otp-form>`,
159+
);
160+
161+
const errorMessage = el.shadowRoot?.querySelector('.error-msg');
162+
expect(errorMessage).to.exist;
163+
});
164+
});

0 commit comments

Comments
 (0)