diff --git a/.vscode/settings.json b/.vscode/settings.json index 71bda39e..09d3958b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -46,6 +46,7 @@ "tab": true, "toast": true, "alert": true, - "expander": true + "expander": true, + "mobile-field": true } } \ No newline at end of file diff --git a/package.json b/package.json index 6acfcc99..1d501190 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,8 @@ "toast", "tab", "alert", - "expander" + "expander", + "mobile-field" ], "exports": { "./*": "./dist/*/index.js" diff --git a/src/mobile-field/MobileField.spec.ts b/src/mobile-field/MobileField.spec.ts new file mode 100644 index 00000000..77bd0d50 --- /dev/null +++ b/src/mobile-field/MobileField.spec.ts @@ -0,0 +1,51 @@ +import { + testClearableBehaviour, + testCustomClearableSlotBehaviour, + testDisabledBehaviour, + testErrorBehaviour, + testHintBehaviour, + testLabelBehaviour, + testPrefixBehaviour, + testSuffixBehaviour, + testValueBehaviour +} from '../core/OmniInputPlaywright.js'; +import { expect, mockEventListener, test, withCoverage } from '../utils/JestPlaywright.js'; +import type { MobileField } from './MobileField.js'; + +test(`Mobile Field - Visual and Behaviour`, async ({ page }) => { + await withCoverage(page, async () => { + await page.goto('/components/mobile-field/'); + await page.evaluate(() => document.fonts.ready); + + const mobileField = page.locator('[data-testid]').first(); + mobileField.evaluate(async (t: MobileField) => { + t.value = ''; + await t.updateComplete; + }); + + // Confirm that the component matches the provided screenshot. + await expect(mobileField).toHaveScreenshot('mobile-field.png'); + + const inputFn = await mockEventListener(mobileField, 'input'); + + const inputField = mobileField.locator('#inputField'); + + const value = '12345'; + await inputField.type(value); + + await expect(inputField).toHaveValue(value); + + await expect(inputFn).toBeCalledTimes(value.length); + await expect(mobileField).toHaveScreenshot('mobile-field-value.png'); + }); +}); + +test('Mobile Field - Label Behaviour', testLabelBehaviour('omni-mobile-field')); +test('Mobile Field - Hint Behaviour', testHintBehaviour('omni-mobile-field')); +test('Mobile Field - Error Behaviour', testErrorBehaviour('omni-mobile-field')); +test('Mobile Field - Value Behaviour', testValueBehaviour('omni-mobile-field')); +test('Mobile Field - Clearable Behaviour', testClearableBehaviour('omni-mobile-field')); +test('Mobile Field - Custom Clear Slot Behaviour', testCustomClearableSlotBehaviour('omni-mobile-field')); +test('Mobile Field - Prefix Behaviour', testPrefixBehaviour('omni-mobile-field')); +test('Mobile Field - Suffix Behaviour', testSuffixBehaviour('omni-mobile-field')); +test('Mobile Field - Disabled Behaviour', testDisabledBehaviour('omni-mobile-field')); diff --git a/src/mobile-field/MobileField.stories.ts b/src/mobile-field/MobileField.stories.ts new file mode 100644 index 00000000..5349068b --- /dev/null +++ b/src/mobile-field/MobileField.stories.ts @@ -0,0 +1,71 @@ +import { html, nothing } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { + BaseArgs, + ClearableStory, + CustomClearableSlot, + DisabledStory, + ErrorStory, + HintStory, + LabelStory, + PrefixStory, + SuffixStory, + ValueStory +} from '../core/OmniInputStories.js'; +import { ifNotEmpty } from '../utils/Directives.js'; +import { ComponentStoryFormat, assignToSlot, getSourceFromLit } from '../utils/StoryUtils.js'; + +import './MobileField.js'; + +interface Args extends BaseArgs { + countryCode: boolean; +} + +export const Interactive: ComponentStoryFormat = { + render: (args) => html` + ${args.prefix ? html`${'\r\n'}${unsafeHTML(assignToSlot('prefix', args.prefix))}` : nothing} + ${args.clear ? html`${'\r\n'}${unsafeHTML(assignToSlot('clear', args.clear))}` : nothing}${ + args.suffix ? html`${'\r\n'}${unsafeHTML(assignToSlot('suffix', args.suffix))}` : nothing + }${args.prefix || args.suffix || args.clear ? '\r\n' : nothing} + `, + frameworkSources: [ + { + framework: 'Vue', + load: (args) => + getSourceFromLit(Interactive!.render!(args), undefined, (s) => + s.replace(' disabled', ' :disabled="true"').replace(' clearable', ' :clearable="true"') + ) + } + ], + name: 'Interactive', + args: { + label: 'Label', + value: '', + hint: '', + error: '', + disabled: false, + prefix: '', + suffix: '', + clear: '', + countryCode: true + } +}; + +export const Label = LabelStory('omni-mobile-field'); +export const Hint = HintStory('omni-mobile-field'); +export const Error_Label = ErrorStory('omni-mobile-field'); +export const Value = ValueStory('omni-mobile-field', 123); +export const Clearable = ClearableStory('omni-mobile-field', 123); +export const Custom_Clear_Slot = CustomClearableSlot('omni-mobile-field', 123); +export const Prefix = PrefixStory('omni-mobile-field'); +export const Suffix = SuffixStory('omni-mobile-field'); +export const Disabled = DisabledStory('omni-mobile-field', 123); diff --git a/src/mobile-field/MobileField.ts b/src/mobile-field/MobileField.ts new file mode 100644 index 00000000..22bf5d71 --- /dev/null +++ b/src/mobile-field/MobileField.ts @@ -0,0 +1,253 @@ +import { css, html } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { ClassInfo, classMap } from 'lit/directives/class-map.js'; +import { live } from 'lit/directives/live.js'; +import { OmniFormElement, ifDefined } from '../core/OmniFormElement.js'; + +/** + * Input control to enter a mobile number. + * + * @import + * ```js + * import '@capitec/omni-components/mobile-field'; + * ``` + * @example + * ```html + * + * + * ``` + * + * @element omni-mobile-field + * + * @cssprop --omni-mobile-field-text-align - Mobile field text align. + * @cssprop --omni-mobile-field-font-color - Mobile field font color. + * @cssprop --omni-mobile-field-font-family - Mobile field font family. + * @cssprop --omni-mobile-field-font-size - Mobile field font size. + * @cssprop --omni-mobile-field-font-weight - Mobile field font weight. + * @cssprop --omni-mobile-field-padding - Mobile field padding. + * @cssprop --omni-mobile-field-height - Mobile field height. + * @cssprop --omni-mobile-field-width - Mobile field width. + * + * @cssprop --omni-mobile-field-disabled-font-color - Mobile field disabled font color. + * @cssprop --omni-mobile-field-error-font-color - Mobile field error font color. + */ +@customElement('omni-mobile-field') +export class MobileField extends OmniFormElement { + @query('#inputField') + private _inputElement?: HTMLInputElement; + + /** + * Indicator if the component should allow the entry of a country code ie: +21 . + * @attr [country-code] + */ + @property({ type: Boolean, reflect: true, attribute: 'country-code' }) countryCode?: boolean; + + /** + * Disables native on screen keyboards for the component. + * @attr [no-native-keyboard] + */ + @property({ type: Boolean, reflect: true, attribute: 'no-native-keyboard' }) noNativeKeyboard?: boolean; + + override connectedCallback() { + super.connectedCallback(); + this.addEventListener('input', this._keyInput.bind(this), { + capture: true + }); + this.addEventListener('keydown', this._keyDown.bind(this), { + capture: true + }); + } + + // Added for browsers that allow text values entered into a input when type is set to number. + override async attributeChangedCallback(name: string, _old: string | null, value: string | null): Promise { + super.attributeChangedCallback(name, _old, value); + if (name === 'value') { + if (new RegExp('^[0-9]+$').test(value as string) === false) { + return; + } + } + } + + override focus(options?: FocusOptions | undefined): void { + if (this._inputElement) { + this._inputElement.focus(options); + } else { + super.focus(options); + } + } + + _keyDown(e: KeyboardEvent) { + const input = this._inputElement as HTMLInputElement; + // Stop alpha keys + if (e.key >= 'a' && e.key <= 'z') { + e.preventDefault(); + return; + } + + console.log('event', e); + console.log('event key', e.key); + console.log('is number', this._isNumber(e.key as string)); + + if (input && e.key) { + if (this._isValid(e.key)) { + } else { + e.preventDefault(); + return; + } + + // console.log('selectionStart', input.selectionStart); + // console.log('selectionEnd',input.selectionEnd); + // if(this.countryCode){ + // if(input.selectionStart === 0 && input.selectionEnd === 0){ + // if(e.shiftKey === true && e.key !== '+'){ + // e.preventDefault(); + // return; + // } + // } else if (!this._isNumber(e.key as string) && e.key !== 'Backspace') { + // e.preventDefault(); + // return; + // } + // } else { + // if (!this._isNumber(e.key as string) && e.key !== 'Backspace') { + // e.preventDefault(); + // return; + // } + // } + + // if (input.selectionStart === 0 && input.selectionEnd === 0) { + + // if(this.countryCode && ( e.key !== '+' || !this._isNumber(e.key as string))){ + // e.preventDefault(); + // return; + // } + // } + + // if(!this._isNumber(e.key as string)){ + // e.preventDefault(); + // return; + // } + } + } + + _keyInput() { + const input = this._inputElement as HTMLInputElement; + this.value = input?.value; + } + + // Check if the value provided is valid, if there is invalid alpha characters they are removed. + _sanitiseMobileValue() { + this.value = this.value?.toString()?.replace(/^([+]\d{2})?\d{10}$/gi, ''); + + if (this._inputElement) { + this._inputElement.value = this.value as string; + } + } + + // Used to check if the value provided in a valid mobile number value. + _isMobileNumber(number: string) { + return /^([+]\d{2})?\d{10}$/.test(number); + } + + _isNumber(number: string) { + return /\d/.test(number); + } + + _isValid(keyValue: string) { + const input = this._inputElement as HTMLInputElement; + + if (keyValue === 'Backspace') { + return true; + } + + if (/\d/.test(keyValue)) { + return true; + } + + if (input.selectionStart === 0 && input.selectionEnd === 0) { + if (keyValue === '+') { + return true; + } + } + + return false; + } + + static override get styles() { + return [ + super.styles, + css` + .field { + flex: 1 1 auto; + + border: none; + background: none; + box-shadow: none; + outline: 0; + padding: 0; + margin: 0; + + text-align: var(--omni-mobile-field-text-align, left); + + color: var(--omni-mobile-field-font-color, var(--omni-font-color)); + font-family: var(--omni-mobile-field-font-family, var(--omni-font-family)); + font-size: var(--omni-mobile-field-font-size, var(--omni-font-size)); + font-weight: var(--omni-mobile-field-font-weight, var(--omni-font-weight)); + padding: var(--omni-mobile-field-padding, 10px); + + height: var(--omni-mobile-field-height, 100%); + width: var(--omni-mobile-field-width, 100%); + } + + .field.disabled { + color: var(--omni-mobile-field-disabled-font-color, #7C7C7C); + } + + .field.error { + color: var(--omni-mobile-field-error-font-color, var(--omni-font-color)); + } + + /* Used to not display default stepper */ + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + /* display: none; <- Crashes Chrome on hover */ + -webkit-appearance: none; + margin: 0; /* <-- Apparently some margin are still there even though it's hidden */ + } + + input[type='number'] { + -moz-appearance: textfield; /* Firefox */ + } + ` + ]; + } + + protected override renderContent() { + const field: ClassInfo = { + field: true, + disabled: this.disabled, + error: this.error as string + }; + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'omni-mobile-field': MobileField; + } +} diff --git a/src/mobile-field/index.ts b/src/mobile-field/index.ts new file mode 100644 index 00000000..62fece67 --- /dev/null +++ b/src/mobile-field/index.ts @@ -0,0 +1 @@ +export * from './MobileField.js';