From a4edf65110e4db1261044c4b301f10e424caa3c8 Mon Sep 17 00:00:00 2001 From: Chris Holt <13071055+chrisdholt@users.noreply.github.com> Date: Wed, 19 Jun 2024 12:40:34 -0700 Subject: [PATCH] feat: update text to use element internals for custom states --- packages/web-components/docs/api-report.md | 10 ++ packages/web-components/src/text/text.spec.ts | 160 ++++++++++++++++-- .../web-components/src/text/text.styles.ts | 117 ++++++------- packages/web-components/src/text/text.ts | 97 ++++++++++- 4 files changed, 316 insertions(+), 68 deletions(-) diff --git a/packages/web-components/docs/api-report.md b/packages/web-components/docs/api-report.md index ec914aac12d7a6..1b59f5ad4694f4 100644 --- a/packages/web-components/docs/api-report.md +++ b/packages/web-components/docs/api-report.md @@ -3122,15 +3122,25 @@ export const TabTemplate: ElementViewTemplate; // @public class Text_2 extends FASTElement { align?: TextAlign; + alignChanged(prev: TextAlign | undefined, next: TextAlign | undefined): void; block: boolean; + // (undocumented) + connectedCallback(): void; + // @internal + elementInternals: ElementInternals; font?: TextFont; + fontChanged(prev: TextFont | undefined, next: TextFont | undefined): void; + // @internal + handleChange(source: any, propertyName: string): void; italic: boolean; nowrap: boolean; size?: TextSize; + sizeChanged(prev: TextSize | undefined, next: TextSize | undefined): void; strikethrough: boolean; truncate: boolean; underline: boolean; weight?: TextWeight; + weightChanged(prev: TextWeight | undefined, next: TextWeight | undefined): void; } export { Text_2 as Text } diff --git a/packages/web-components/src/text/text.spec.ts b/packages/web-components/src/text/text.spec.ts index 96dad554fd5a61..34b8257a597df6 100644 --- a/packages/web-components/src/text/text.spec.ts +++ b/packages/web-components/src/text/text.spec.ts @@ -56,7 +56,7 @@ test.describe('Text Component', () => { await expect(element).toBeVisible(); }); - test(`should set and reflect and update the nowrap attribute and property on the internal control`, async () => { + test(`should set and reflect and update the nowrap attribute and property when provided`, async () => { await element.evaluate((node: Text) => { node.nowrap = true; }); @@ -71,7 +71,21 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('nowrap', false); }); - test(`should set and reflect and update the truncate attribute and property on the internal control`, async () => { + test(`should add a custom state matching the nowrap for the font attribute when provided`, async () => { + await element.evaluate((node: Text) => { + node.nowrap = true; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('nowrap'))).toBe(true); + + await element.evaluate((node: Text) => { + node.nowrap = false; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('nowrap'))).toBe(false); + }); + + test(`should set and reflect and update the truncate attribute and property when provided`, async () => { await element.evaluate((node: Text) => { node.truncate = true; }); @@ -86,7 +100,21 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('truncate', false); }); - test(`should set and reflect and update the italic attribute and property on the internal control`, async () => { + test(`should add a custom state matching the truncate for the font attribute when provided`, async () => { + await element.evaluate((node: Text) => { + node.truncate = true; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('truncate'))).toBe(true); + + await element.evaluate((node: Text) => { + node.truncate = false; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('truncate'))).toBe(false); + }); + + test(`should set and reflect and update the italic attribute and property when provided`, async () => { await element.evaluate((node: Text) => { node.italic = true; }); @@ -101,7 +129,21 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('italic', false); }); - test(`should set and reflect and update the underline attribute and property on the internal control`, async () => { + test(`should add a custom state matching the italic for the font attribute when provided`, async () => { + await element.evaluate((node: Text) => { + node.italic = true; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('italic'))).toBe(true); + + await element.evaluate((node: Text) => { + node.italic = false; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('italic'))).toBe(false); + }); + + test(`should set and reflect and update the underline attribute and property when provided`, async () => { await element.evaluate((node: Text) => { node.underline = true; }); @@ -116,7 +158,21 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('underline', false); }); - test(`should set and reflect and update the strikethrough attribute and property on the internal control`, async () => { + test(`should add a custom state matching the underline for the font attribute when provided`, async () => { + await element.evaluate((node: Text) => { + node.underline = true; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('underline'))).toBe(true); + + await element.evaluate((node: Text) => { + node.underline = false; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('underline'))).toBe(false); + }); + + test(`should set and reflect and update the strikethrough attribute and property when provided`, async () => { await element.evaluate((node: Text) => { node.strikethrough = true; }); @@ -131,7 +187,21 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('strikethrough', false); }); - test(`should set and reflect and update the block attribute and property on the internal control`, async () => { + test(`should add a custom state matching the strikethrough for the font attribute when provided`, async () => { + await element.evaluate((node: Text) => { + node.strikethrough = true; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('strikethrough'))).toBe(true); + + await element.evaluate((node: Text) => { + node.strikethrough = false; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('strikethrough'))).toBe(false); + }); + + test(`should set and reflect and update the block attribute and property when provided`, async () => { await element.evaluate((node: Text) => { node.block = true; }); @@ -146,8 +216,22 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('block', false); }); + test(`should add a custom state matching the block for the font attribute when provided`, async () => { + await element.evaluate((node: Text) => { + node.block = true; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('block'))).toBe(true); + + await element.evaluate((node: Text) => { + node.block = false; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has('block'))).toBe(false); + }); + for (const [, value] of Object.entries(sizeAttributes)) { - test(`should set and reflect the size attribute to \`${value}\` on the internal control`, async () => { + test(`should set and reflect the size attribute to \`${value}\` when provided`, async () => { await element.evaluate((node: Text, sizeValue: string) => { node.size = sizeValue as TextSize; }, value as string); @@ -155,9 +239,23 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('size', `${value}`); await expect(element).toHaveAttribute('size', `${value}`); }); + + test(`should add a custom state matching the \`${value}\` for the size attribute when provided`, async () => { + await element.evaluate((node: Text, sizeValue: string) => { + node.size = sizeValue as TextSize; + }, value as string); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has(value))).toBe(true); + + await element.evaluate((node: Text) => { + node.size = undefined; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has(value))).toBe(false); + }); } for (const [, value] of Object.entries(weightAttributes)) { - test(`should set and reflect the weight attribute to \`${value}\` on the internal control`, async () => { + test(`should set and reflect the weight attribute to \`${value}\` when provided`, async () => { await element.evaluate((node: Text, weightValue: string) => { node.weight = weightValue as TextWeight; }, value as string); @@ -165,9 +263,23 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('weight', `${value}`); await expect(element).toHaveAttribute('weight', `${value}`); }); + + test(`should add a custom state matching the \`${value}\` for the weight attribute when provided`, async () => { + await element.evaluate((node: Text, weightValue: string) => { + node.weight = weightValue as TextWeight; + }, value as string); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has(value))).toBe(true); + + await element.evaluate((node: Text) => { + node.weight = undefined; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has(value))).toBe(false); + }); } for (const [, value] of Object.entries(alignAttributes)) { - test(`should set and reflect the align attribute to \`${value}\` on the internal control`, async () => { + test(`should set and reflect the align attribute to \`${value}\` when provided`, async () => { await element.evaluate((node: Text, alignValue: string) => { node.align = alignValue as TextAlign; }, value as string); @@ -175,9 +287,23 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('align', `${value}`); await expect(element).toHaveAttribute('align', `${value}`); }); + + test(`should add a custom state matching the \`${value}\` for the align attribute when provided`, async () => { + await element.evaluate((node: Text, alignValue: string) => { + node.align = alignValue as TextAlign; + }, value as string); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has(value))).toBe(true); + + await element.evaluate((node: Text) => { + node.align = undefined; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has(value))).toBe(false); + }); } for (const [, value] of Object.entries(fontAttributes)) { - test(`should set and reflect the font attribute to \`${value}\` on the internal control`, async () => { + test(`should set and reflect the font attribute to \`${value}\` when provided`, async () => { await element.evaluate((node: Text, fontValue: string) => { node.font = fontValue as TextFont; }, value as string); @@ -185,5 +311,19 @@ test.describe('Text Component', () => { await expect(element).toHaveJSProperty('font', `${value}`); await expect(element).toHaveAttribute('font', `${value}`); }); + + test(`should add a custom state matching the \`${value}\` for the font attribute when provided`, async () => { + await element.evaluate((node: Text, fontValue: string) => { + node.font = fontValue as TextFont; + }, value as string); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has(value))).toBe(true); + + await element.evaluate((node: Text) => { + node.font = undefined; + }); + + expect(await element.evaluate((node: Text) => node.elementInternals.states.has(value))).toBe(false); + }); } }); diff --git a/packages/web-components/src/text/text.styles.ts b/packages/web-components/src/text/text.styles.ts index 380e0c3f5eee43..0a01f0c9bedbd2 100644 --- a/packages/web-components/src/text/text.styles.ts +++ b/packages/web-components/src/text/text.styles.ts @@ -30,6 +30,30 @@ import { lineHeightHero900, } from '../theme/design-tokens.js'; +/** + * Selector for the `nowrap` state. + * @public + */ +const nowrapState = css.partial`:is([state--nowrap], :state(nowrap))`; + +/** + * Selector for the `truncate` state. + * @public + */ +const truncateState = css.partial`:is([state--truncate], :state(truncate))`; + +/** + * Selector for the `underline` state. + * @public + */ +const underlineState = css.partial`:is([state--underline], :state(underline))`; + +/** + * Selector for the `strikethrough` state. + * @public + */ +const strikethroughState = css.partial`:is([state--strikethrough], :state(strikethrough))`; + /** Text styles * @public */ @@ -38,126 +62,105 @@ export const styles = css` :host { contain: content; - } - - ::slotted(*) { font-family: ${fontFamilyBase}; font-size: ${fontSizeBase300}; line-height: ${lineHeightBase300}; font-weight: ${fontWeightRegular}; text-align: start; - white-space: normal; - overflow: visible; - text-overflow: clip; - margin: 0; - display: inline; } - :host([nowrap]) ::slotted(*), - :host([nowrap]) { + :host(${nowrapState}), + :host(${nowrapState}) ::slotted(*) { white-space: nowrap; overflow: hidden; } - :host([truncate]) ::slotted(*), - :host([truncate]) { + :host(${truncateState}), + :host(${truncateState}) ::slotted(*) { text-overflow: ellipsis; } - :host([block]), - :host([block]) ::slotted(*), - :host([block]) { + :host(:is([state--block], :state(block))) { display: block; } - :host([italic]) ::slotted(*), - :host([italic]) { + :host(:is([state--italic], :state(italic))) { font-style: italic; } - :host([underline]) ::slotted(*), - :host([underline]) { + :host(${underlineState}) { text-decoration-line: underline; } - :host([strikethrough]) ::slotted(*), - :host([strikethrough]) { + :host(${strikethroughState}) { text-decoration-line: line-through; } - :host([underline][strikethrough]) ::slotted(*), - :host([underline][strikethrough]) { + :host(${underlineState}${strikethroughState}) { text-decoration-line: line-through underline; } - :host([size='100']) ::slotted(*), - :host([size='100']) { + :host(:is([state--size-100], :state(size-100))) { font-size: ${fontSizeBase100}; line-height: ${lineHeightBase100}; } - :host([size='200']) ::slotted(*), - :host([size='200']) { + :host(:is([state--size-200], :state(size-200))) { font-size: ${fontSizeBase200}; line-height: ${lineHeightBase200}; } - :host([size='400']) ::slotted(*), - :host([size='400']) { + :host(:is([state--size-400], :state(size-400))) { font-size: ${fontSizeBase400}; line-height: ${lineHeightBase400}; } - :host([size='500']) ::slotted(*), - :host([size='500']) { + :host(:is([state--size-500], :state(size-500))) { font-size: ${fontSizeBase500}; line-height: ${lineHeightBase500}; } - :host([size='600']) ::slotted(*), - :host([size='600']) { + :host(:is([state--size-600], :state(size-600))) { font-size: ${fontSizeBase600}; line-height: ${lineHeightBase600}; } - :host([size='700']) ::slotted(*), - :host([size='700']) { + :host(:is([state--size-700], :state(size-700))) { font-size: ${fontSizeHero700}; line-height: ${lineHeightHero700}; } - :host([size='800']) ::slotted(*), - :host([size='800']) { + :host(:is([state--size-800], :state(size-800))) { font-size: ${fontSizeHero800}; line-height: ${lineHeightHero800}; } - :host([size='900']) ::slotted(*), - :host([size='900']) { + :host(:is([state--size-900], :state(size-900))) { font-size: ${fontSizeHero900}; line-height: ${lineHeightHero900}; } - :host([size='1000']) ::slotted(*), - :host([size='1000']) { + :host(:is([state--size-1000], :state(size-1000))) { font-size: ${fontSizeHero1000}; line-height: ${lineHeightHero1000}; } - :host([font='monospace']) ::slotted(*), - :host([font='monospace']) { + :host(:is([state--monospace], :state(monospace))) { font-family: ${fontFamilyMonospace}; } - :host([font='numeric']) ::slotted(*), - :host([font='numeric']) { + :host(:is([state--numeric], :state(numeric))) { font-family: ${fontFamilyNumeric}; } - :host([weight='medium']) ::slotted(*), - :host([weight='medium']) { + :host(:is([state--medium], :state(medium))) { font-weight: ${fontWeightMedium}; } - :host([weight='semibold']) ::slotted(*), - :host([weight='semibold']) { + :host(:is([state--semibold], :state(semibold))) { font-weight: ${fontWeightSemibold}; } - :host([weight='bold']) ::slotted(*), - :host([weight='bold']) { + :host(:is([state--bold], :state(bold))) { font-weight: ${fontWeightBold}; } - :host([align='center']) ::slotted(*), - :host([align='center']) { + :host(:is([state--center], :state(center))) { text-align: center; } - :host([align='end']) ::slotted(*), - :host([align='end']) { + :host(:is([state--end], :state(end))) { text-align: end; } - :host([align='justify']) ::slotted(*), - :host([align='justify']) { + :host(:is([state--justify], :state(justify))) { text-align: justify; } + + ::slotted(*) { + display: inherit; + font: inherit; + line-height: inherit; + text-decoration-line: inherit; + text-align: inherit; + text-decoration-line: inherit; + margin: 0; + } `; diff --git a/packages/web-components/src/text/text.ts b/packages/web-components/src/text/text.ts index c0cd166a45557d..8d7ed9b4e6c81b 100644 --- a/packages/web-components/src/text/text.ts +++ b/packages/web-components/src/text/text.ts @@ -1,4 +1,5 @@ -import { attr, FASTElement } from '@microsoft/fast-element'; +import { attr, FASTElement, Observable } from '@microsoft/fast-element'; +import { toggleState } from '../utils/element-internals.js'; import type { TextAlign, TextFont, TextSize, TextWeight } from './text.options.js'; /** @@ -6,6 +7,13 @@ import type { TextAlign, TextFont, TextSize, TextWeight } from './text.options.j * @public */ export class Text extends FASTElement { + /** + * The internal {@link https://developer.mozilla.org/docs/Web/API/ElementInternals | `ElementInternals`} instance for the component. + * + * @internal + */ + public elementInternals: ElementInternals = this.attachInternals(); + /** * The text will not wrap * NOTE: In Fluent UI React v9 this is "wrap" @@ -79,6 +87,20 @@ export class Text extends FASTElement { @attr size?: TextSize; + /** + * Handles changes to size attribute custom states + * @param prev - the previous state + * @param next - the next state + */ + public sizeChanged(prev: TextSize | undefined, next: TextSize | undefined) { + if (prev) { + toggleState(this.elementInternals, `size-${prev}`, false); + } + if (next) { + toggleState(this.elementInternals, `size-${next}`, true); + } + } + /** * THe Text font * @@ -89,6 +111,20 @@ export class Text extends FASTElement { @attr font?: TextFont; + /** + * Handles changes to font attribute custom states + * @param prev - the previous state + * @param next - the next state + */ + public fontChanged(prev: TextFont | undefined, next: TextFont | undefined) { + if (prev) { + toggleState(this.elementInternals, prev, false); + } + if (next) { + toggleState(this.elementInternals, next, true); + } + } + /** * THe Text weight * @@ -99,6 +135,20 @@ export class Text extends FASTElement { @attr weight?: TextWeight; + /** + * Handles changes to weight attribute custom states + * @param prev - the previous state + * @param next - the next state + */ + public weightChanged(prev: TextWeight | undefined, next: TextWeight | undefined) { + if (prev) { + toggleState(this.elementInternals, prev, false); + } + if (next) { + toggleState(this.elementInternals, next, true); + } + } + /** * THe Text align * @@ -108,4 +158,49 @@ export class Text extends FASTElement { */ @attr align?: TextAlign; + + /** + * Handles changes to align attribute custom states + * @param prev - the previous state + * @param next - the next state + */ + public alignChanged(prev: TextAlign | undefined, next: TextAlign | undefined) { + if (prev) { + toggleState(this.elementInternals, prev, false); + } + if (next) { + toggleState(this.elementInternals, next, true); + } + } + + public connectedCallback(): void { + super.connectedCallback(); + + Observable.getNotifier(this).subscribe(this); + + Object.keys(this.$fastController.definition.attributeLookup).forEach(key => { + this.handleChange(this, key); + }); + } + + /** + * Handles changes to observable properties + * @internal + * @param source - the source of the change + * @param propertyName - the property name being changed + */ + public handleChange(source: any, propertyName: string) { + switch (propertyName) { + case 'nowrap': + case 'truncate': + case 'italic': + case 'underline': + case 'strikethrough': + case 'block': + toggleState(this.elementInternals, propertyName, !!this[propertyName]); + break; + default: + break; + } + } }