Skip to content

Commit

Permalink
feat: update text input to use custom states for presentational attri…
Browse files Browse the repository at this point in the history
…butes (#31753)
  • Loading branch information
chrisdholt authored Jun 18, 2024
1 parent 42a1d70 commit b101c34
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "feat: update text input to use Element Internals custom states for presentational attributes",
"packageName": "@fluentui/web-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
2 changes: 2 additions & 0 deletions packages/web-components/docs/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -3160,6 +3160,7 @@ export type TextFont = ValuesOf<typeof TextFont>;
// @public
export class TextInput extends FASTElement {
appearance?: TextInputAppearance;
appearanceChanged(prev: TextInputAppearance | undefined, next: TextInputAppearance | undefined): void;
autocomplete?: string;
autofocus: boolean;
// @internal
Expand All @@ -3175,6 +3176,7 @@ export class TextInput extends FASTElement {
// @internal
controlLabel: HTMLLabelElement;
controlSize?: TextInputControlSize;
controlSizeChanged(prev: TextInputControlSize | undefined, next: TextInputControlSize | undefined): void;
// @internal
defaultSlottedNodes: Node[];
// @internal
Expand Down
18 changes: 18 additions & 0 deletions packages/web-components/src/styles/states/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { css } from '@microsoft/fast-element';

/**
* Selector for the `filled-lighter` state.
* @public
*/
export const filledLighterState = css.partial`:is([state--filled-lighter], :state(filled-lighter))`;

/**
* Selector for the `filled-darker` state.
* @public
*/
export const filledDarkerState = css.partial`:is([state--filled-darker], :state(filled-darker))`;

/**
* Selector for the `ghost` state.
* @public
Expand Down Expand Up @@ -42,6 +54,12 @@ export const subtleState = css.partial`:is([state--subtle], :state(subtle))`;
*/
export const tintState = css.partial`:is([state--tint], :state(tint))`;

/**
* Selector for the `underline` state.
* @public
*/
export const underlineState = css.partial`:is([state--underline], :state(underline))`;

/**
* Selector for the `transparent` state.
* @public
Expand Down
79 changes: 79 additions & 0 deletions packages/web-components/src/text-input/text-input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,42 @@ test.describe('TextInput', () => {
await expect(element).toHaveJSProperty('controlSize', 'small');
});

test('should add a custom state matching the `size` attribute when provided', async ({ page }) => {
const element = page.locator('fluent-text-input');

await page.setContent(/* html */ `
<fluent-text-input control-size="small"></fluent-text-input>
`);

await element.evaluate((node: TextInput) => {
node.controlSize = 'small';
});

expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('small'))).toBe(true);

await element.evaluate((node: TextInput) => {
node.controlSize = 'medium';
});

expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('small'))).toBe(false);
expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('medium'))).toBe(true);

await element.evaluate((node: TextInput) => {
node.controlSize = 'large';
});

expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('medium'))).toBe(false);
expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('large'))).toBe(true);

await element.evaluate((node: TextInput) => {
node.controlSize = undefined;
});

expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('small'))).toBe(false);
expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('medium'))).toBe(false);
expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('large'))).toBe(false);
});

test('should reflect `appearance` attribute values', async ({ page }) => {
const element = page.locator('fluent-text-input');

Expand Down Expand Up @@ -190,6 +226,49 @@ test.describe('TextInput', () => {
await expect(element).toHaveJSProperty('appearance', 'filled-lighter');
});

test('should add a custom state matching the `appearance` attribute when provided', async ({ page }) => {
const element = page.locator('fluent-text-input');

await page.setContent(/* html */ `
<fluent-text-input></fluent-text-input>
`);

await element.evaluate((node: TextInput) => {
node.appearance = 'outline';
});

expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('outline'))).toBe(true);

await element.evaluate((node: TextInput) => {
node.appearance = 'underline';
});

expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('outline'))).toBe(false);
expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('underline'))).toBe(true);

await element.evaluate((node: TextInput) => {
node.appearance = 'filled-lighter';
});

expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('underline'))).toBe(false);
expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('filled-lighter'))).toBe(true);

await element.evaluate((node: TextInput) => {
node.appearance = 'filled-darker';
});

expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('filled-lighter'))).toBe(false);
expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('filled-darker'))).toBe(true);

await element.evaluate((node: TextInput) => {
node.appearance = undefined;
});

expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('outline'))).toBe(false);
expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('underline'))).toBe(false);
expect(await element.evaluate((node: TextInput) => node.elementInternals.states.has('filled-lighter'))).toBe(false);
});

test('should have an undefined `value` property when no `value` attribute is set', async ({ page }) => {
const element = page.locator('fluent-text-input');

Expand Down
52 changes: 30 additions & 22 deletions packages/web-components/src/text-input/text-input.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ import {
strokeWidthThin,
} from '../theme/design-tokens.js';
import { display } from '../utils/display.js';
import {
filledDarkerState,
filledLighterState,
largeState,
outlineState,
smallState,
underlineState,
} from '../styles/states/index.js';

/**
* Styles for the TextInput component.
Expand Down Expand Up @@ -168,7 +176,7 @@ export const styles: ElementStyles = css`
:host(:focus-within:active) .root:after {
border-bottom-color: ${colorCompoundBrandStrokePressed};
}
:host([appearance='outline']:focus-within) .root {
:host(${outlineState}:focus-within) .root {
border: ${strokeWidthThin} solid ${colorNeutralStroke1};
}
:host(:focus-within) .control {
Expand All @@ -187,70 +195,70 @@ export const styles: ElementStyles = css`
color: ${colorNeutralForegroundInverted};
background-color: ${colorNeutralBackgroundInverted};
}
:host([control-size='small']) .control {
:host(${smallState}) .control {
font-size: ${fontSizeBase200};
font-weight: ${fontWeightRegular};
line-height: ${lineHeightBase200};
}
:host([control-size='small']) .root {
:host(${smallState}) .root {
height: 24px;
gap: ${spacingHorizontalXXS};
padding: 0 ${spacingHorizontalSNudge};
}
:host([control-size='small']) ::slotted([slot='start']),
:host([control-size='small']) ::slotted([slot='end']) {
:host(${smallState}) ::slotted([slot='start']),
:host(${smallState}) ::slotted([slot='end']) {
font-size: ${fontSizeBase400};
}
:host([control-size='large']) .control {
:host(${largeState}) .control {
font-size: ${fontSizeBase400};
font-weight: ${fontWeightRegular};
line-height: ${lineHeightBase400};
}
:host([control-size='large']) .root {
:host(${largeState}) .root {
height: 40px;
gap: ${spacingHorizontalS};
padding: 0 ${spacingHorizontalM};
}
:host([control-size='large']) ::slotted([slot='start']),
:host([control-size='large']) ::slotted([slot='end']) {
:host(${largeState}) ::slotted([slot='start']),
:host(${largeState}) ::slotted([slot='end']) {
font-size: ${fontSizeBase600};
}
:host([appearance='underline']) .root {
:host(${underlineState}) .root {
background: ${colorTransparentBackground};
border: 0;
border-radius: 0;
border-bottom: ${strokeWidthThin} solid ${colorNeutralStrokeAccessible};
}
:host([appearance='underline']:hover) .root {
:host(${underlineState}:hover) .root {
border-bottom-color: ${colorNeutralStrokeAccessibleHover};
}
:host([appearance='underline']:active) .root {
:host(${underlineState}:active) .root {
border-bottom-color: ${colorNeutralStrokeAccessiblePressed};
}
:host([appearance='underline']:focus-within) .root {
:host(${underlineState}:focus-within) .root {
border: 0;
border-bottom-color: ${colorNeutralStrokeAccessiblePressed};
}
:host([appearance='underline'][disabled]) .root {
:host(${underlineState}[disabled]) .root {
border-bottom-color: ${colorNeutralStrokeDisabled};
}
:host([appearance='filled-lighter']) .root,
:host([appearance='filled-darker']) .root {
:host(${filledLighterState}) .root,
:host(${filledDarkerState}) .root {
border: ${strokeWidthThin} solid ${colorTransparentStroke};
box-shadow: ${shadow2};
}
:host([appearance='filled-lighter']) .root {
:host(${filledLighterState}) .root {
background: ${colorNeutralBackground1};
}
:host([appearance='filled-darker']) .root {
:host(${filledDarkerState}) .root {
background: ${colorNeutralBackground3};
}
:host([appearance='filled-lighter']:hover) .root,
:host([appearance='filled-darker']:hover) .root {
:host(${filledLighterState}:hover) .root,
:host(${filledDarkerState}:hover) .root {
border-color: ${colorTransparentStrokeInteractive};
}
:host([appearance='filled-lighter']:active) .root,
:host([appearance='filled-darker']:active) .root {
:host(${filledLighterState}:active) .root,
:host(${filledDarkerState}:active) .root {
border-color: ${colorTransparentStrokeInteractive};
background: ${colorNeutralBackground3};
}
Expand Down
29 changes: 29 additions & 0 deletions packages/web-components/src/text-input/text-input.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { attr, FASTElement, nullableNumberConverter, Observable, observable } from '@microsoft/fast-element';
import { StartEnd } from '../patterns/start-end.js';
import { applyMixins } from '../utils/apply-mixins.js';
import { toggleState } from '../utils/element-internals.js';
import type { TextInputControlSize } from './text-input.options.js';
import { ImplicitSubmissionBlockingTypes, TextInputAppearance, TextInputType } from './text-input.options.js';

Expand All @@ -27,6 +28,20 @@ export class TextInput extends FASTElement {
@attr
public appearance?: TextInputAppearance;

/**
* Handles changes to appearance attribute custom states
* @param prev - the previous state
* @param next - the next state
*/
public appearanceChanged(prev: TextInputAppearance | undefined, next: TextInputAppearance | undefined) {
if (prev) {
toggleState(this.elementInternals, `${prev}`, false);
}
if (next) {
toggleState(this.elementInternals, `${next}`, true);
}
}

/**
* Indicates the element's autocomplete state.
* @see The {@link https://developer.mozilla.org/docs/Web/HTML/Attributes/autocomplete | `autocomplete`} attribute
Expand Down Expand Up @@ -59,6 +74,20 @@ export class TextInput extends FASTElement {
@attr({ attribute: 'control-size' })
public controlSize?: TextInputControlSize;

/**
* Handles changes to `control-size` attribute custom states
* @param prev - the previous state
* @param next - the next state
*/
public controlSizeChanged(prev: TextInputControlSize | undefined, next: TextInputControlSize | undefined) {
if (prev) {
toggleState(this.elementInternals, `${prev}`, false);
}
if (next) {
toggleState(this.elementInternals, `${next}`, true);
}
}

/**
* The default slotted content. This is the content that appears in the text field label.
*
Expand Down

0 comments on commit b101c34

Please sign in to comment.