From 7fda712246b7223f58509898be3338a4384e890f Mon Sep 17 00:00:00 2001 From: Joel Cabuyao Date: Fri, 14 Aug 2026 09:46:53 +0800 Subject: [PATCH] fix(material/form-field): center outlined label irrespective of outline width and font size The floating label of the outline appearance is positioned relatively inside the notch, which means that its offset is added on top of the notch's content box. That box is inset by the outline's top border, so the label ended up `outline-width` below the center of the form field. This is most noticeable when the outline width is animated, e.g. from 1px to 2px on hover, because the label jumps as the outline grows. The label is also an inline block which makes it align against the notch's baseline. Any difference between the label's font metrics and the inherited line height pushed the label further down, which `translateY(-50%)` cannot account for since it only knows about the label's own height. Expose the current outline width through a variable and offset the resting label by it, and align the label with the top of the notch's content box so that it no longer depends on the surrounding line height. Note that the offset only applies while the label is resting, because the notch drops its top border while it is notched. Fixes #31474 --- .../form-field/_mdc-text-field-structure.scss | 11 +++++ src/material/input/input.spec.ts | 42 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/material/form-field/_mdc-text-field-structure.scss b/src/material/form-field/_mdc-text-field-structure.scss index e84c27a5a683..b940f16342e0 100644 --- a/src/material/form-field/_mdc-text-field-structure.scss +++ b/src/material/form-field/_mdc-text-field-structure.scss @@ -193,6 +193,11 @@ $fallbacks: m3-form-field.get-tokens(); display: inline-block; position: relative; max-width: 100%; + vertical-align: top; + } + + .mdc-notched-outline:not(.mdc-notched-outline--notched) & { + margin-top: calc(-1 * var(--mat-mdc-form-field-outline-width, 1px)); } .mdc-text-field--outlined & { @@ -373,9 +378,11 @@ $fallbacks: m3-form-field.get-tokens(); border: none; border-top: 1px solid; border-bottom: 1px solid; + --mat-mdc-form-field-outline-width: 1px; .mdc-text-field--focused & { border-width: 2px; + --mat-mdc-form-field-outline-width: 2px; } // Moved out into variables because the selectors we inherited were too long. @@ -385,6 +392,8 @@ $fallbacks: m3-form-field.get-tokens(); #{$enabled-selector} & { border-color: token-utils.slot(form-field-outlined-outline-color, $fallbacks); border-width: token-utils.slot(form-field-outlined-outline-width, $fallbacks); + --mat-mdc-form-field-outline-width: #{token-utils.slot( + form-field-outlined-outline-width, $fallbacks)}; } #{$enabled-selector}#{$hover-selector} & { @@ -413,6 +422,8 @@ $fallbacks: m3-form-field.get-tokens(); #{$enabled-selector}.mdc-text-field--focused .mdc-notched-outline & { border-width: token-utils.slot(form-field-outlined-focus-outline-width, $fallbacks); + --mat-mdc-form-field-outline-width: #{token-utils.slot( + form-field-outlined-focus-outline-width, $fallbacks)}; } } diff --git a/src/material/input/input.spec.ts b/src/material/input/input.spec.ts index f7bf11ae5ab9..03c79a2321a6 100644 --- a/src/material/input/input.spec.ts +++ b/src/material/input/input.spec.ts @@ -1719,6 +1719,48 @@ describe('MatFormField without label', () => { }); }); +describe('MatFormField label alignment', () => { + let fixture: ComponentFixture; + let formField: HTMLElement; + let wrapper: HTMLElement; + let label: HTMLElement; + + /** Distance between the center of the resting label and the center of the form field. */ + function getLabelOffsetFromCenter(): number { + const wrapperRect = wrapper.getBoundingClientRect(); + const labelRect = label.getBoundingClientRect(); + return labelRect.top + labelRect.height / 2 - (wrapperRect.top + wrapperRect.height / 2); + } + + beforeEach(() => { + fixture = TestBed.createComponent(MatInputWithAppearance); + fixture.componentInstance.appearance = 'outline'; + fixture.detectChanges(); + + formField = fixture.nativeElement.querySelector('.mat-mdc-form-field'); + wrapper = fixture.nativeElement.querySelector('.mat-mdc-text-field-wrapper'); + label = fixture.nativeElement.querySelector('.mat-mdc-floating-label'); + }); + + it('should center the resting label irrespective of the outline width', () => { + expect(Math.abs(getLabelOffsetFromCenter())).toBeLessThan(1); + + formField.style.setProperty('--mat-form-field-outlined-outline-width', '8px'); + + // Sanity check that the token is actually being applied so that the assertion below + // can't pass because the form field styles aren't loaded. + const notch = fixture.nativeElement.querySelector('.mdc-notched-outline__notch'); + expect(getComputedStyle(notch).borderTopWidth).toBe('8px'); + expect(Math.abs(getLabelOffsetFromCenter())).toBeLessThan(1); + }); + + it('should center the resting label irrespective of the label font size', () => { + formField.style.setProperty('--mat-form-field-outlined-label-text-size', '10px'); + expect(getComputedStyle(label).fontSize).toBe('10px'); + expect(Math.abs(getLabelOffsetFromCenter())).toBeLessThan(1); + }); +}); + @Component({ template: `