From f2835dd3949cf0c092f4be52969bf62e93c962f5 Mon Sep 17 00:00:00 2001 From: Nick Andrews Date: Fri, 27 Oct 2023 15:02:58 -0400 Subject: [PATCH] feat(chip-field): add new `addOnBlur` property/attribute (#408) --- src/dev/pages/chip-field/chip-field.html | 1 + src/dev/pages/chip-field/chip-field.ts | 6 ++ src/lib/chip-field/chip-field-constants.ts | 5 ++ src/lib/chip-field/chip-field-foundation.ts | 29 +++++++++- src/lib/chip-field/chip-field.ts | 27 ++++++++- src/lib/field/field-constants.ts | 23 +++++--- .../components/chip-field/chip-field-args.ts | 10 +++- .../src/components/chip-field/chip-field.mdx | 8 +++ .../chip-field/chip-field.stories.tsx | 3 + src/test/spec/chip-field/chip-field.spec.ts | 58 ++++++++++++++++++- 10 files changed, 154 insertions(+), 16 deletions(-) diff --git a/src/dev/pages/chip-field/chip-field.html b/src/dev/pages/chip-field/chip-field.html index 30f6f3cb3..a40a5ac31 100644 --- a/src/dev/pages/chip-field/chip-field.html +++ b/src/dev/pages/chip-field/chip-field.html @@ -8,6 +8,7 @@ { type: 'switch', label: 'Invalid', id: 'opt-invalid' }, { type: 'switch', label: 'Disabled', id: 'opt-disabled' }, { type: 'switch', label: 'Dense', id: 'opt-dense' }, + { type: 'switch', label: 'Add on blur', id: 'opt-add-on-blur' }, { type: 'button', label: 'Populate chips', id: 'opt-btn-populate' }, { type: 'button', label: 'Clear chips', id: 'opt-btn-clear' }, ] diff --git a/src/dev/pages/chip-field/chip-field.ts b/src/dev/pages/chip-field/chip-field.ts index 6dd1ebd13..46cf991e3 100644 --- a/src/dev/pages/chip-field/chip-field.ts +++ b/src/dev/pages/chip-field/chip-field.ts @@ -20,6 +20,7 @@ const requiredToggle = document.querySelector('#opt-required') as ISwitchCompone const invalidToggle = document.querySelector('#opt-invalid') as ISwitchComponent; const disabledToggle = document.querySelector('#opt-disabled') as ISwitchComponent; const denseToggle = document.querySelector('#opt-dense') as ISwitchComponent; +const onBlurToggle = document.querySelector('#opt-add-on-blur') as ISwitchComponent; const populateButton = document.querySelector('#opt-btn-populate') as HTMLButtonElement; const clearButton = document.querySelector('#opt-btn-clear') as HTMLButtonElement; @@ -27,6 +28,7 @@ requiredToggle.addEventListener('forge-switch-select', updateRequiredState); invalidToggle.addEventListener('forge-switch-select', updateInvalidState); disabledToggle.addEventListener('forge-switch-select', updateDisabledState); denseToggle.addEventListener('forge-switch-select', updateDenseState); +onBlurToggle.addEventListener('forge-switch-select', updateOnBlurProperty); populateButton.addEventListener('click', () => populateMembers(45)); clearButton.addEventListener('click', removeAllMembers); @@ -180,6 +182,10 @@ function updateDenseState({ detail: isDense }: CustomEvent): void { chips.forEach(({ dense }) => dense = isDense); } +function updateOnBlurProperty({ detail: addOnBlur }: CustomEvent): void { + simpleChipField.addOnBlur = addOnBlur; +} + function setChipsDisabledState(isDisabled: boolean): void { const chips = autocompleteChipField.querySelectorAll('forge-chip'); chips.forEach(({ disabled }) => disabled = isDisabled); diff --git a/src/lib/chip-field/chip-field-constants.ts b/src/lib/chip-field/chip-field-constants.ts index 777ec2c53..739224b60 100644 --- a/src/lib/chip-field/chip-field-constants.ts +++ b/src/lib/chip-field/chip-field-constants.ts @@ -28,7 +28,12 @@ const events = { MEMBER_REMOVED: `${elementName}-member-removed` }; +const attributes = { + ADD_ON_BLUR: 'add-on-blur' +}; + export const CHIP_FIELD_CONSTANTS = { + attributes, elementName, classes, slots, diff --git a/src/lib/chip-field/chip-field-foundation.ts b/src/lib/chip-field/chip-field-foundation.ts index 94ae56086..096cb3fe1 100644 --- a/src/lib/chip-field/chip-field-foundation.ts +++ b/src/lib/chip-field/chip-field-foundation.ts @@ -3,9 +3,12 @@ import { IChipFieldAdapter } from './chip-field-adapter'; import { CHIP_FIELD_CONSTANTS } from './chip-field-constants'; import { IFieldFoundation, FieldFoundation } from '../field/field-foundation'; -export interface IChipFieldFoundation extends IFieldFoundation {} +export interface IChipFieldFoundation extends IFieldFoundation { + addOnBlur: boolean; +} export class ChipFieldFoundation extends FieldFoundation implements IChipFieldFoundation { + private _addOnBlur = false; private _memberSlotListener: () => void; private _inputContainerMouseDownListener: (evt: MouseEvent) => void; private _handleRootKeyDown: (event: KeyboardEvent) => void; @@ -35,6 +38,18 @@ export class ChipFieldFoundation extends FieldFoundation implements IChipFieldFo this._adapter.removeInputListener('keydown', this._handleKeyDown); } + /** Controls adding a member of entered text on blur. */ + public get addOnBlur(): boolean { + return this._addOnBlur; + } + public set addOnBlur(value: boolean) { + value = Boolean(value); + if (this._addOnBlur !== value) { + this._addOnBlur = value; + this._adapter.toggleHostAttribute(CHIP_FIELD_CONSTANTS.attributes.ADD_ON_BLUR, this._addOnBlur); + } + } + private _onInputContainerMouseDown(evt: MouseEvent): void { evt.preventDefault(); this._adapter.focusInput(); @@ -43,6 +58,11 @@ export class ChipFieldFoundation extends FieldFoundation implements IChipFieldFo protected _onBlur(event: FocusEvent): void { const input = event.target as HTMLInputElement; + + if (this._addOnBlur) { + this._addMember(input); + } + input.value = ''; super._onBlur(event); } @@ -79,9 +99,13 @@ export class ChipFieldFoundation extends FieldFoundation implements IChipFieldFo break; case 'Esc': case 'Escape': - case 'Tab': input.value = ''; break; + case 'Tab': + if (!this._addOnBlur) { + input.value = ''; + } + break; default: break; } @@ -152,7 +176,6 @@ export class ChipFieldFoundation extends FieldFoundation implements IChipFieldFo if (cleanInputValue && cleanInputValue.length > 0) { this._adapter.emitHostEvent(CHIP_FIELD_CONSTANTS.events.MEMBER_ADDED, cleanInputValue); } - input.value = ''; } diff --git a/src/lib/chip-field/chip-field.ts b/src/lib/chip-field/chip-field.ts index c68002f3f..a8901a6c7 100644 --- a/src/lib/chip-field/chip-field.ts +++ b/src/lib/chip-field/chip-field.ts @@ -1,4 +1,4 @@ -import { CustomElement, attachShadowTemplate } from '@tylertech/forge-core'; +import { CustomElement, FoundationProperty, attachShadowTemplate, coerceBoolean } from '@tylertech/forge-core'; import { ChipFieldAdapter } from './chip-field-adapter'; import { ChipFieldFoundation } from './chip-field-foundation'; import { CHIP_FIELD_CONSTANTS } from './chip-field-constants'; @@ -7,8 +7,11 @@ import { FieldComponent, IFieldComponent } from '../field/field'; import template from './chip-field.html'; import styles from './chip-field.scss'; +import { FIELD_CONSTANTS } from '../field/field-constants'; -export interface IChipFieldComponent extends IFieldComponent { } +export interface IChipFieldComponent extends IFieldComponent { + addOnBlur: boolean; +} declare global { interface HTMLElementTagNameMap { @@ -31,9 +34,29 @@ declare global { dependencies: [ChipComponent] }) export class ChipFieldComponent extends FieldComponent implements IChipFieldComponent { + public static get observedAttributes(): string[] { + return [ + ...Object.values(FIELD_CONSTANTS.attributes), + CHIP_FIELD_CONSTANTS.attributes.ADD_ON_BLUR + ]; + } + constructor() { super(); attachShadowTemplate(this, template, styles); this._foundation = new ChipFieldFoundation(new ChipFieldAdapter(this)); } + + public attributeChangedCallback(name: string, oldValue: string, newValue: string): void { + switch (name) { + case CHIP_FIELD_CONSTANTS.attributes.ADD_ON_BLUR: + this.addOnBlur = coerceBoolean(newValue); + return; + } + super.attributeChangedCallback(name, oldValue, newValue); + } + + /** Controls whether or not the value should be set onBlur */ + @FoundationProperty() + public declare addOnBlur: boolean; } diff --git a/src/lib/field/field-constants.ts b/src/lib/field/field-constants.ts index 2c064faf8..807be0655 100644 --- a/src/lib/field/field-constants.ts +++ b/src/lib/field/field-constants.ts @@ -1,12 +1,3 @@ -const attributes = { - DENSITY: 'density', - FLOAT_LABEL_TYPE: 'float-label-type', - SHAPE: 'shape', - INVALID: 'invalid', - REQUIRED: 'required', - HOST_LABEL_FLOATING: `forge-label-floating` -}; - const selectors = { INPUT: 'input,textarea' }; @@ -31,8 +22,22 @@ const classes = { LABEL: 'forge-field--label' }; +const observedAttributes = { + DENSITY: 'density', + FLOAT_LABEL_TYPE: 'float-label-type', + SHAPE: 'shape', + INVALID: 'invalid', + REQUIRED: 'required', + HOST_LABEL_FLOATING: `forge-label-floating` +}; + +const attributes = { + ...observedAttributes +}; + export const FIELD_CONSTANTS = { attributes, + observedAttributes, observedInputAttributes, selectors, classes diff --git a/src/stories/src/components/chip-field/chip-field-args.ts b/src/stories/src/components/chip-field/chip-field-args.ts index 849607c5e..02a35f1b9 100644 --- a/src/stories/src/components/chip-field/chip-field-args.ts +++ b/src/stories/src/components/chip-field/chip-field-args.ts @@ -14,6 +14,7 @@ export interface IChipFieldProps { hasLeading: boolean; hasTrailing: boolean; hasAddonEnd: boolean; + addOnBlur: boolean; } export const argTypes = { @@ -129,5 +130,12 @@ export const argTypes = { table: { category: 'Slots', }, - } + }, + addOnBlur: { + control: 'boolean', + description: '', + table: { + category: 'Properties', + }, + }, }; diff --git a/src/stories/src/components/chip-field/chip-field.mdx b/src/stories/src/components/chip-field/chip-field.mdx index 3b2450543..f0d866e6a 100644 --- a/src/stories/src/components/chip-field/chip-field.mdx +++ b/src/stories/src/components/chip-field/chip-field.mdx @@ -74,6 +74,14 @@ Valid values: `auto` (default), `always`. + + +Controls the behavior of the blur event. When set to true, pressing tab or clicking away from the field will emit the add member event with the current text value. + +Note: This property should only be used with a simple chip field, not with an autocomplete. + + + --- diff --git a/src/stories/src/components/chip-field/chip-field.stories.tsx b/src/stories/src/components/chip-field/chip-field.stories.tsx index a0fff4c2d..dd821ff1d 100644 --- a/src/stories/src/components/chip-field/chip-field.stories.tsx +++ b/src/stories/src/components/chip-field/chip-field.stories.tsx @@ -30,6 +30,7 @@ export const Default: Story = ({ hasTrailing = false, hasHelperText = false, hasAddonEnd = false, + addOnBlur = false }) => { const chipFieldRef = useRef(); const addMember = (evt: CustomEvent) => { @@ -68,6 +69,7 @@ export const Default: Story = ({ floatLabelType={floatLabelType} shape={shape} invalid={invalid} + addOnBlur={addOnBlur} required={required} style={{width: '559px'}}> @@ -109,6 +111,7 @@ Default.args = { hasTrailing: false, hasHelperText: false, hasAddonEnd: false, + addOnBlur: false } as IChipFieldProps; export const WithAutocomplete: Story<{}> = () => { diff --git a/src/test/spec/chip-field/chip-field.spec.ts b/src/test/spec/chip-field/chip-field.spec.ts index 6456ed26b..286f6830c 100644 --- a/src/test/spec/chip-field/chip-field.spec.ts +++ b/src/test/spec/chip-field/chip-field.spec.ts @@ -82,6 +82,18 @@ describe('ChipFieldComponent', function(this: ITestContext) { expect(this.context.foundation['_isInitialized']).toBeTrue(); }); + it('should set the addOnBlur property to false when the attribute is not applied', function(this: ITestContext) { + this.context = setupTestContext(); + + expect(this.context.component.addOnBlur).toBeFalse(); + }); + + it('should set the addOnBlur property to true when the attribute is set to true', function(this: ITestContext) { + this.context = setupTestContext(); + this.context.component.setAttribute(CHIP_FIELD_CONSTANTS.attributes.ADD_ON_BLUR, ''); + + expect(this.context.component.addOnBlur).toBe(true); + }); it('should float label if value is set before adding to DOM', async function(this: ITestContext) { this.context = setupTestContext(false); @@ -1192,8 +1204,52 @@ describe('ChipFieldComponent', function(this: ITestContext) { const inputIsActive = getActiveElement() === getNativeInput(this.context.component); expect(inputIsActive).toBeTrue(); }); - }); + it('should set the addOnBlur property to true when the attribute is set to true', function(this: ITestContext) { + this.context = setupTestContext(); + this.context.component.setAttribute(CHIP_FIELD_CONSTANTS.attributes.ADD_ON_BLUR, ''); + + expect(this.context.component.addOnBlur).toBe(true); + }); + + it('should set the addOnBlur property to false when the attribute is set to false', function(this: ITestContext) { + this.context = setupTestContext(); + + this.context.component.addOnBlur = true; + expect(this.context.component.addOnBlur).toBeTrue(); + + this.context.component.setAttribute(CHIP_FIELD_CONSTANTS.attributes.ADD_ON_BLUR, 'false'); + expect(this.context.component.addOnBlur).toBe(false); + }); + + it('chips should not be added when addOnBlur is set to false and the "Tab" key is pressed', function(this: ITestContext) { + this.context = setupTestContext(); + + expect(this.context.component.addOnBlur).toBeFalse(); + + const listener = jasmine.createSpy('add member listener'); + this.context.component.addEventListener(CHIP_FIELD_CONSTANTS.events.MEMBER_ADDED, listener); + + const inputEl = getNativeInput(this.context.component); + inputEl.focus(); + inputEl.value = 'test'; + dispatchKeydownEvent(inputEl, 'Tab'); + + expect(listener).toHaveBeenCalledTimes(0); + expect(inputEl.value).withContext('the input value should have been cleared').toBe(''); + }); + + it('chips should be added when addOnBlur is set to true and the mouse is clicked outside of the input', function(this: ITestContext) { + this.context = setupTestContext(); + this.context.component.setAttribute(CHIP_FIELD_CONSTANTS.attributes.ADD_ON_BLUR, 'true'); + const listener = jasmine.createSpy('add member listener'); + this.context.component.addEventListener(CHIP_FIELD_CONSTANTS.events.MEMBER_ADDED, listener); + getNativeInput(this.context.component).value = 'test'; + getNativeInput(this.context.component).focus(); + getNativeInput(this.context.component).blur(); + expect(listener).toHaveBeenCalledTimes(1) + }); + }); }); describe('With no label', function(this: ITestContext) {