diff --git a/docs/components/text-field.md b/docs/components/text-field.md index 462ee8731f..a324e65aeb 100644 --- a/docs/components/text-field.md +++ b/docs/components/text-field.md @@ -160,7 +160,7 @@ Multi-line text fields behave like elements. Textareas can specify the initial number of `rows`. Use CSS `width`, `height`, -and `resize` to control the resize behavior of a textarea. +`resize`, and `field-sizing` to control the resize behavior of a textarea. @@ -183,6 +183,25 @@ resize](images/textfield/usage-textarea.webp) ``` +Set `field-sizing: content` on a textarea to automatically grow with its content +in supporting browsers. When `field-sizing: content` is used, `rows` and `cols` +no longer set default dimensions, so use CSS `width`, `min-height`, and +`max-height` to bound the size. + +```html + + + +``` + ### Icons Text fields may display optional icons. Use icons to describe the input method diff --git a/field/internal/_content.scss b/field/internal/_content.scss index c3fe0ac3ce..f93646b03d 100644 --- a/field/internal/_content.scss +++ b/field/internal/_content.scss @@ -114,6 +114,10 @@ $_enter-delay: $_label-duration - $_visible-duration; // Use margin for textareas since they will scroll over the label if not. margin-top: var(--_top-space); margin-bottom: var(--_bottom-space); + // Allow unbroken strings to wrap within explicit widths when using + // `field-sizing: content` (since `.container` has + // `min-width: min-content`). + overflow-wrap: anywhere; } :hover .content { diff --git a/field/internal/_shared.scss b/field/internal/_shared.scss index ee1e555e79..c40ff494f6 100644 --- a/field/internal/_shared.scss +++ b/field/internal/_shared.scss @@ -57,6 +57,18 @@ resize: inherit; } + // Inherit field-sizing down through slotted content so that it can be + // customized on the host. + .field, + .container-overflow, + .container, + .middle, + .content, + .content slot, + .content ::slotted(*) { + field-sizing: inherit; + } + .resizable:not(.disabled) .container { // `resize` is inherited from the host, but only applies to the container // when resizable. diff --git a/textfield/filled-text-field_test.ts b/textfield/filled-text-field_test.ts index 80dd7030b4..f040e5fecc 100644 --- a/textfield/filled-text-field_test.ts +++ b/textfield/filled-text-field_test.ts @@ -6,6 +6,9 @@ // import 'jasmine'; (google3-only) +import {html} from 'lit'; + +import {Environment} from '../testing/environment.js'; import {createTokenTests} from '../testing/tokens.js'; import {MdFilledTextField} from './filled-text-field.js'; @@ -15,3 +18,90 @@ describe('', () => { createTokenTests(MdFilledTextField.styles); }); }); + +describe('', () => { + const env = new Environment(); + + describe('field-sizing', () => { + it('grows height with multiline content', async () => { + if (!CSS.supports('field-sizing', 'content')) { + return; + } + const root = env.render(html` + + `); + const field = root.querySelector('md-filled-text-field'); + if (!field) { + throw new Error('Missing .'); + } + await env.waitForStability(); + const initialHeight = field.getBoundingClientRect().height; + + field.value = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; + await env.waitForStability(); + + expect(field.getBoundingClientRect().height) + .withContext('expanded textarea height') + .toBeGreaterThan(initialHeight); + }); + + it('wraps unbroken text within an explicit width', async () => { + if (!CSS.supports('field-sizing', 'content')) { + return; + } + const root = env.render(html` + + `); + const field = root.querySelector('md-filled-text-field'); + if (!field) { + throw new Error('Missing .'); + } + await env.waitForStability(); + const initialHeight = field.getBoundingClientRect().height; + + field.value = 'a'.repeat(200); + await env.waitForStability(); + + const rect = field.getBoundingClientRect(); + expect(rect.width) + .withContext('bounded host width') + .toBeLessThanOrEqual(200); + expect(field.scrollWidth) + .withContext('no horizontal overflow') + .toBeLessThanOrEqual(200); + expect(rect.height) + .withContext('wrapped height') + .toBeGreaterThan(initialHeight); + }); + + it('caps height and scrollHeight at max-height', async () => { + if (!CSS.supports('field-sizing', 'content')) { + return; + } + const root = env.render(html` + + `); + const field = root.querySelector('md-filled-text-field'); + if (!field) { + throw new Error('Missing .'); + } + + await env.waitForStability(); + + expect(field.getBoundingClientRect().height) + .withContext('capped host height') + .toBeLessThanOrEqual(120); + expect(field.scrollHeight) + .withContext('no vertical overflow past max-height') + .toBeLessThanOrEqual(120); + }); + }); +}); diff --git a/textfield/internal/_shared.scss b/textfield/internal/_shared.scss index a9c036e3d2..db068e3044 100644 --- a/textfield/internal/_shared.scss +++ b/textfield/internal/_shared.scss @@ -23,6 +23,7 @@ .text-field, .field { + field-sizing: inherit; width: 100%; } diff --git a/textfield/outlined-text-field_test.ts b/textfield/outlined-text-field_test.ts index 30c40d21f7..6c89c471ea 100644 --- a/textfield/outlined-text-field_test.ts +++ b/textfield/outlined-text-field_test.ts @@ -6,6 +6,9 @@ // import 'jasmine'; (google3-only) +import {html} from 'lit'; + +import {Environment} from '../testing/environment.js'; import {createTokenTests} from '../testing/tokens.js'; import {MdOutlinedTextField} from './outlined-text-field.js'; @@ -15,3 +18,90 @@ describe('', () => { createTokenTests(MdOutlinedTextField.styles); }); }); + +describe('', () => { + const env = new Environment(); + + describe('field-sizing', () => { + it('grows height with multiline content', async () => { + if (!CSS.supports('field-sizing', 'content')) { + return; + } + const root = env.render(html` + + `); + const field = root.querySelector('md-outlined-text-field'); + if (!field) { + throw new Error('Missing .'); + } + await env.waitForStability(); + const initialHeight = field.getBoundingClientRect().height; + + field.value = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; + await env.waitForStability(); + + expect(field.getBoundingClientRect().height) + .withContext('expanded textarea height') + .toBeGreaterThan(initialHeight); + }); + + it('wraps unbroken text within an explicit width', async () => { + if (!CSS.supports('field-sizing', 'content')) { + return; + } + const root = env.render(html` + + `); + const field = root.querySelector('md-outlined-text-field'); + if (!field) { + throw new Error('Missing .'); + } + await env.waitForStability(); + const initialHeight = field.getBoundingClientRect().height; + + field.value = 'a'.repeat(200); + await env.waitForStability(); + + const rect = field.getBoundingClientRect(); + expect(rect.width) + .withContext('bounded host width') + .toBeLessThanOrEqual(200); + expect(field.scrollWidth) + .withContext('no horizontal overflow') + .toBeLessThanOrEqual(200); + expect(rect.height) + .withContext('wrapped height') + .toBeGreaterThan(initialHeight); + }); + + it('caps height and scrollHeight at max-height', async () => { + if (!CSS.supports('field-sizing', 'content')) { + return; + } + const root = env.render(html` + + `); + const field = root.querySelector('md-outlined-text-field'); + if (!field) { + throw new Error('Missing .'); + } + + await env.waitForStability(); + + expect(field.getBoundingClientRect().height) + .withContext('capped host height') + .toBeLessThanOrEqual(120); + expect(field.scrollHeight) + .withContext('no vertical overflow past max-height') + .toBeLessThanOrEqual(120); + }); + }); +});