From ccc1a44172edf129bc7f31f400c56cba22d01be0 Mon Sep 17 00:00:00 2001 From: Sam Richardson Date: Wed, 14 Feb 2024 18:34:10 -0500 Subject: [PATCH] fix(field): only float when a label is present --- src/lib/field-next/field-adapter.ts | 17 ++++ src/lib/field-next/field-constants.ts | 11 ++- src/lib/field-next/field-foundation.ts | 19 +++- src/lib/field-next/field.scss | 24 ++--- src/lib/field-next/field.test.ts | 121 +++++++++++++++++++++++-- 5 files changed, 170 insertions(+), 22 deletions(-) diff --git a/src/lib/field-next/field-adapter.ts b/src/lib/field-next/field-adapter.ts index dcd6b6828..5ab170b23 100644 --- a/src/lib/field-next/field-adapter.ts +++ b/src/lib/field-next/field-adapter.ts @@ -7,6 +7,7 @@ import { FieldLabelPosition, FieldSlot, FIELD_CONSTANTS } from './field-constant export interface IFieldAdapter extends IBaseAdapter { addSlotChangeListener(slotName: FieldSlot, listener: () => void): void; + removeSlotChangeListener(slotName: FieldSlot, listener: () => void): void; addPopoverIconClickListener(listener: () => void): void; removePopoverIconClickListener(listener: () => void): void; attachResizeContainer(): void; @@ -20,6 +21,7 @@ export class FieldAdapter extends BaseAdapter implements IField private readonly _rootElement: HTMLElement; private readonly _containerElement: HTMLElement; private readonly _labelElement: HTMLElement; + private readonly _labelSlotElement: HTMLSlotElement; private readonly _startSlotElement: HTMLSlotElement; private readonly _endSlotElement: HTMLSlotElement; private readonly _accessorySlotElement: HTMLSlotElement; @@ -37,6 +39,7 @@ export class FieldAdapter extends BaseAdapter implements IField this._rootElement = getShadowElement(component, FIELD_CONSTANTS.selectors.ROOT); this._containerElement = getShadowElement(component, FIELD_CONSTANTS.selectors.CONTAINER); this._labelElement = getShadowElement(component, FIELD_CONSTANTS.selectors.LABEL); + this._labelSlotElement = getShadowElement(component, FIELD_CONSTANTS.selectors.LABEL_SLOT) as HTMLSlotElement; this._startSlotElement = getShadowElement(component, FIELD_CONSTANTS.selectors.START_SLOT) as HTMLSlotElement; this._endSlotElement = getShadowElement(component, FIELD_CONSTANTS.selectors.END_SLOT) as HTMLSlotElement; this._accessorySlotElement = getShadowElement(component, FIELD_CONSTANTS.selectors.ACCESSORY_SLOT) as HTMLSlotElement; @@ -47,6 +50,9 @@ export class FieldAdapter extends BaseAdapter implements IField public addSlotChangeListener(slotName: FieldSlot, listener: (evt: Event) => void): void { switch (slotName) { + case 'label': + this._labelSlotElement.addEventListener('slotchange', listener); + break; case 'start': this._startSlotElement.addEventListener('slotchange', listener); break; @@ -65,6 +71,14 @@ export class FieldAdapter extends BaseAdapter implements IField } } + public removeSlotChangeListener(slotName: FieldSlot, listener: (evt: Event) => void): void { + switch (slotName) { + case 'label': + this._labelSlotElement.removeEventListener('slotchange', listener); + break; + } + } + public addPopoverIconClickListener(listener: () => void): void { this._popoverIconElement.addEventListener('click', listener); } @@ -149,6 +163,9 @@ export class FieldAdapter extends BaseAdapter implements IField */ public handleSlotChange(slotName: FieldSlot): void { switch (slotName) { + case 'label': + toggleClass(this._rootElement, !!this._labelSlotElement.assignedNodes().length, FIELD_CONSTANTS.classes.HAS_LABEL); + break; case 'start': toggleClass(this._rootElement, !!this._startSlotElement.assignedNodes().length, FIELD_CONSTANTS.classes.HAS_START); break; diff --git a/src/lib/field-next/field-constants.ts b/src/lib/field-next/field-constants.ts index 5f1903271..8555ff844 100644 --- a/src/lib/field-next/field-constants.ts +++ b/src/lib/field-next/field-constants.ts @@ -28,6 +28,7 @@ const attributes = { const classes = { FLOATING_IN: 'forge-field--floating-in', FLOATING_OUT: 'forge-field--floating-out', + HAS_LABEL: 'forge-field--has-label', HAS_START: 'forge-field--has-start', HAS_END: 'forge-field--has-end', HAS_ACCESSORY: 'forge-field--has-accessory', @@ -40,6 +41,7 @@ const selectors = { ROOT: '#root', CONTAINER: '#container', LABEL: '#label', + LABEL_SLOT: 'slot[name=label]', START_SLOT: 'slot[name=start]', END_SLOT: 'slot[name=end]', ACCESSORY_SLOT: 'slot[name=accessory]', @@ -68,6 +70,10 @@ const animations = { FLOAT_OUT_LABEL: 'float-out-label-animation' }; +const values = { + ANIMATION_TIMEOUT_DURATION: 1000 +}; + export const FIELD_CONSTANTS = { elementName, observedAttributes, @@ -76,7 +82,8 @@ export const FIELD_CONSTANTS = { selectors, events, defaults, - animations + animations, + values }; export type FieldVariant = 'plain' | 'outlined' | 'tonal' | 'filled' | 'raised'; @@ -86,4 +93,4 @@ export type FieldDensity = Density | 'extra-small' | 'extra-large'; export type FieldLabelPosition = 'inline-start' | 'inline-end' | 'block-start' | 'inset' | 'none'; export type FieldLabelAlignment = 'default' | 'center' | 'baseline' | 'start' | 'end'; export type FieldSupportTextInset = 'start' | 'end' | 'both' | 'none'; -export type FieldSlot = 'start' | 'end' | 'accessory' | 'support-start' | 'support-end'; +export type FieldSlot = 'label' | 'start' | 'end' | 'accessory' | 'support-start' | 'support-end'; diff --git a/src/lib/field-next/field-foundation.ts b/src/lib/field-next/field-foundation.ts index 9031b551d..9e568d228 100644 --- a/src/lib/field-next/field-foundation.ts +++ b/src/lib/field-next/field-foundation.ts @@ -41,6 +41,7 @@ export class FieldFoundation implements IFieldFoundation { private _multiline = false; private _supportTextInset: FieldSupportTextInset = FIELD_CONSTANTS.defaults.DEFAULT_SUPPORT_TEXT_INSET; + private _labelSlotListener: () => void; private _startSlotListener: () => void; private _endSlotListener: () => void; private _accessorySlotListener: () => void; @@ -49,6 +50,7 @@ export class FieldFoundation implements IFieldFoundation { private _popoverIconClickListener: () => void; constructor(private _adapter: IFieldAdapter) { + this._labelSlotListener = () => this._onSlotChange('label'); this._startSlotListener = () => this._onSlotChange('start'); this._endSlotListener = () => this._onSlotChange('end'); this._accessorySlotListener = () => this._onSlotChange('accessory'); @@ -64,6 +66,9 @@ export class FieldFoundation implements IFieldFoundation { this._adapter.addSlotChangeListener('support-start', this._supportStartSlotListener); this._adapter.addSlotChangeListener('support-end', this._supportEndSlotListener); this._adapter.setLabelPosition(this._labelPosition); + if (this._labelPosition === 'inset') { + this._adapter.addSlotChangeListener('label', this._labelSlotListener); + } if (this._popoverIcon) { this._adapter.addPopoverIconClickListener(this._popoverIconClickListener); } @@ -95,9 +100,19 @@ export class FieldFoundation implements IFieldFoundation { if (this._labelPosition !== value) { this._labelPosition = value; this._adapter.setHostAttribute(FIELD_CONSTANTS.attributes.LABEL_POSITION, this._labelPosition); - if (this._adapter.isConnected) { - this._adapter.setLabelPosition(this._labelPosition); + + if (!this._adapter.isConnected) { + return; + } + + this._adapter.setLabelPosition(this._labelPosition); + + if (this._labelPosition === 'inset') { + this._adapter.addSlotChangeListener('label', this._labelSlotListener); + } else { + this._adapter.removeSlotChangeListener('label', this._labelSlotListener); } + this._labelSlotListener(); } } diff --git a/src/lib/field-next/field.scss b/src/lib/field-next/field.scss index e1c9242aa..f0f671381 100644 --- a/src/lib/field-next/field.scss +++ b/src/lib/field-next/field.scss @@ -322,16 +322,16 @@ $variants: ( // :host(#{map.get($label-positions, inset)}[float-label]) { - .label { - @include core.floating-label; - } - - .input { - @include core.floating-input; - } - - .forge-field { - &--floating-in { + .forge-field--has-label { + .label { + @include core.floating-label; + } + + .input { + @include core.floating-input; + } + + &.forge-field--floating-in { .label { @include core.float-in-label; } @@ -352,7 +352,7 @@ $variants: ( } :host(#{map.get($label-positions, inset)}:not([float-label])) { - .forge-field--floating-out { + .forge-field--has-label.forge-field--floating-out { .label { @include core.float-out-label; } @@ -364,7 +364,7 @@ $variants: ( } :host(#{map.get($label-positions, inset)}[float-label][multiline]) { - .forge-field:not(.forge-field--floating-in) { + .forge-field--has-label:not(.forge-field--floating-in) { ::slotted(textarea) { @include core.resize-container-slotted-floating-input; } diff --git a/src/lib/field-next/field.test.ts b/src/lib/field-next/field.test.ts index 4d12a0acc..38e8a4ba2 100644 --- a/src/lib/field-next/field.test.ts +++ b/src/lib/field-next/field.test.ts @@ -320,38 +320,136 @@ describe('Field', () => { describe('slots', () => { it('should add class when start slot has content', async () => { const harness = await createDefaultFixture(); - harness.slotContent('start'); + harness.addSlottedContent('start'); await tick(); expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_START)).to.be.true; }); + it('should not add class when start slot has no content', async () => { + const harness = await createDefaultFixture(); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_START)).to.be.false; + }); + + it('should remove class when start slot content is removed', async () => { + const harness = await createDefaultFixture(); + harness.addSlottedContent('start'); + await tick(); + harness.removeSlottedContent('start'); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_START)).to.be.false; + }); + it('should add class when end slot has content', async () => { const harness = await createDefaultFixture(); - harness.slotContent('end'); + harness.addSlottedContent('end'); await tick(); expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_END)).to.be.true; }); + it('should not add class when end slot has no content', async () => { + const harness = await createDefaultFixture(); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_END)).to.be.false; + }); + + it('should remove class when end slot content is removed', async () => { + const harness = await createDefaultFixture(); + harness.addSlottedContent('end'); + await tick(); + harness.removeSlottedContent('end'); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_END)).to.be.false; + }); + it('should add class when accessory slot has content', async () => { const harness = await createDefaultFixture(); - harness.slotContent('accessory'); + harness.addSlottedContent('accessory'); await tick(); expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_ACCESSORY)).to.be.true; }); + it('should not add class when accessory slot has no content', async () => { + const harness = await createDefaultFixture(); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_ACCESSORY)).to.be.false; + }); + + it('should remove class when accessory slot content is removed', async () => { + const harness = await createDefaultFixture(); + harness.addSlottedContent('accessory'); + await tick(); + harness.removeSlottedContent('accessory'); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_ACCESSORY)).to.be.false; + }); + it('should add class when support text start slot has content', async () => { const harness = await createDefaultFixture(); - harness.slotContent('support-text-start'); + harness.addSlottedContent('support-text-start'); await tick(); expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_SUPPORT_START)).to.be.true; }); + it('should not add class when support text start slot has no content', async () => { + const harness = await createDefaultFixture(); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_SUPPORT_START)).to.be.false; + }); + + it('should remove class when support text start slot content is removed', async () => { + const harness = await createDefaultFixture(); + harness.addSlottedContent('support-text-start'); + await tick(); + harness.removeSlottedContent('support-text-start'); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_SUPPORT_START)).to.be.false; + }); + it('should add class when support text end slot has content', async () => { const harness = await createDefaultFixture(); - harness.slotContent('support-text-end'); + harness.addSlottedContent('support-text-end'); await tick(); expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_SUPPORT_END)).to.be.true; }); + + it('should not add class when support text end slot has no content', async () => { + const harness = await createDefaultFixture(); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_SUPPORT_END)).to.be.false; + }); + + it('should remove class when support text end slot content is removed', async () => { + const harness = await createDefaultFixture(); + harness.addSlottedContent('support-text-end'); + await tick(); + harness.removeSlottedContent('support-text-end'); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_SUPPORT_END)).to.be.false; + }); + + it('should add class when label slot has content and label position is inset', async () => { + const harness = await createFixture({ labelPosition: 'inset' }); + harness.addSlottedContent('label'); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_LABEL)).to.be.true; + }); + + it('should not add class when label slot has content and label position is not inset', async () => { + const harness = await createFixture({ labelPosition: 'inline-start' }); + harness.addSlottedContent('label'); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_LABEL)).to.be.false; + }); + + it('should remove class when label slot content is removed and label position is inset', async () => { + const harness = await createFixture({ labelPosition: 'inset' }); + harness.addSlottedContent('label'); + await tick(); + harness.removeSlottedContent('label'); + await tick(); + expect(harness.rootElement.classList.contains(FIELD_CONSTANTS.classes.HAS_LABEL)).to.be.false; + }); }); describe('label position', () => { @@ -412,6 +510,7 @@ describe('Field', () => { const harness = await createFixture({ labelPosition: 'inset' }); const animationSpy = spy(); harness.rootElement.addEventListener('animationstart', animationSpy); + harness.addSlottedContent('label'); harness.element.floatLabel = true; await tick(); @@ -423,6 +522,7 @@ describe('Field', () => { const harness = await createFixture({ labelPosition: 'inset', floatLabel: true }); const animationSpy = spy(); harness.rootElement.addEventListener('animationstart', animationSpy); + harness.addSlottedContent('label'); harness.element.floatLabel = false; await tick(); @@ -434,6 +534,7 @@ describe('Field', () => { const harness = await createFixture({ labelPosition: 'inset' }); const animationSpy = spy(); harness.rootElement.addEventListener('animationstart', animationSpy); + harness.addSlottedContent('label'); harness.element.floatLabelWithoutAnimation(true); await tick(); @@ -445,6 +546,7 @@ describe('Field', () => { const harness = await createFixture({ labelPosition: 'inset', floatLabel: true }); const animationSpy = spy(); harness.rootElement.addEventListener('animationstart', animationSpy); + harness.addSlottedContent('label'); harness.element.floatLabelWithoutAnimation(false); await tick(); @@ -482,11 +584,18 @@ class FieldHarness extends TestHarness { ]}); } - public slotContent(slotName: string): void { + public addSlottedContent(slotName: string): void { const div = document.createElement('div'); div.slot = slotName; this.element.appendChild(div); } + + public removeSlottedContent(slotName: string): void { + const el = this.element.querySelector(`[slot="${slotName}"]`); + if (el) { + el.remove(); + } + } } interface FieldFixtureConfig {