Skip to content

Commit 1cc64f5

Browse files
asyncLizcopybara-github
authored andcommitted
feat(text-field): add character counter
PiperOrigin-RevId: 472518034
1 parent 5241b76 commit 1cc64f5

File tree

2 files changed

+102
-58
lines changed

2 files changed

+102
-58
lines changed

textfield/lib/_text-field.scss

+4
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,8 @@
2525
cursor: default;
2626
}
2727
}
28+
29+
.md3-text-field__counter {
30+
white-space: nowrap;
31+
}
2832
}

textfield/lib/text-field.ts

+98-58
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ export abstract class TextField extends LitElement {
105105
* LTR notation for fractions.
106106
*/
107107
@property({type: String}) textDirection = '';
108+
/**
109+
* The ID on the character counter element, used for SSR.
110+
*/
111+
@property({type: String}) counterId = 'counter';
108112

109113
// ARIA
110114
// TODO(b/210730484): replace with @soyParam annotation
@@ -480,10 +484,10 @@ export abstract class TextField extends LitElement {
480484
/** @soyTemplate */
481485
override render(): TemplateResult {
482486
return html`
483-
<span class="md3-text-field ${classMap(this.getRenderClasses())}">
484-
${this.renderField()}
485-
</span>
486-
`;
487+
<span class="md3-text-field ${classMap(this.getRenderClasses())}">
488+
${this.renderField()}
489+
</span>
490+
`;
487491
}
488492

489493
/** @soyTemplate */
@@ -502,21 +506,22 @@ export abstract class TextField extends LitElement {
502506
const inputValue = this.getInputValue();
503507

504508
return staticHtml`<${this.fieldTag}
505-
class="md3-text-field__field"
506-
?disabled=${this.disabled}
507-
?error=${this.error}
508-
?focused=${this.focused}
509-
?hasEnd=${this.hasTrailingIcon}
510-
?hasStart=${this.hasLeadingIcon}
511-
.label=${this.label}
512-
?populated=${!!inputValue}
513-
?required=${this.required}
514-
>
515-
${this.renderLeadingIcon()}
516-
${prefix}${input}${suffix}
517-
${this.renderTrailingIcon()}
518-
${this.renderSupportingText()}
519-
</${this.fieldTag}>`;
509+
class="md3-text-field__field"
510+
?disabled=${this.disabled}
511+
?error=${this.error}
512+
?focused=${this.focused}
513+
?hasEnd=${this.hasTrailingIcon}
514+
?hasStart=${this.hasLeadingIcon}
515+
.label=${this.label}
516+
?populated=${!!inputValue}
517+
?required=${this.required}
518+
>
519+
${this.renderLeadingIcon()}
520+
${prefix}${input}${suffix}
521+
${this.renderTrailingIcon()}
522+
${this.renderSupportingText()}
523+
${this.renderCounter()}
524+
</${this.fieldTag}>`;
520525
}
521526

522527
/**
@@ -525,11 +530,11 @@ export abstract class TextField extends LitElement {
525530
*/
526531
protected renderLeadingIcon(): TemplateResult {
527532
return html`
528-
<span class="md3-text-field__icon md3-text-field__icon--leading"
529-
slot="start">
530-
<slot name="leadingicon" @slotchange=${this.handleIconChange}></slot>
531-
</span>
532-
`;
533+
<span class="md3-text-field__icon md3-text-field__icon--leading"
534+
slot="start">
535+
<slot name="leadingicon" @slotchange=${this.handleIconChange}></slot>
536+
</span>
537+
`;
533538
}
534539

535540
/**
@@ -538,11 +543,11 @@ export abstract class TextField extends LitElement {
538543
*/
539544
protected renderTrailingIcon(): TemplateResult {
540545
return html`
541-
<span class="md3-text-field__icon md3-text-field__icon--trailing"
542-
slot="end">
543-
<slot name="trailingicon" @slotchange=${this.handleIconChange}></slot>
544-
</span>
545-
`;
546+
<span class="md3-text-field__icon md3-text-field__icon--trailing"
547+
slot="end">
548+
<slot name="trailingicon" @slotchange=${this.handleIconChange}></slot>
549+
</span>
550+
`;
546551
}
547552

548553
/** @soyTemplate */
@@ -552,8 +557,7 @@ export abstract class TextField extends LitElement {
552557
const ariaActiveDescendantValue = this.ariaActiveDescendant || undefined;
553558
const ariaAutoCompleteValue = this.ariaAutoComplete || undefined;
554559
const ariaControlsValue = this.ariaControls || undefined;
555-
const ariaDescribedByValue =
556-
this.getSupportingText() ? this.supportingTextId : undefined;
560+
const ariaDescribedByValue = this.getAriaDescribedBy() || undefined;
557561
const ariaExpandedValue = this.ariaExpanded || undefined;
558562
const ariaLabelValue = this.ariaLabel || this.label || undefined;
559563
const ariaLabelledByValue = this.ariaLabelledBy || undefined;
@@ -571,40 +575,51 @@ export abstract class TextField extends LitElement {
571575
// TODO(b/243805848): remove `as unknown as number` once lit analyzer is
572576
// fixed
573577
return html`<input
574-
class="md3-text-field__input"
575-
style=${styleMap(style)}
576-
aria-activedescendant=${ifDefined(ariaActiveDescendantValue)}
577-
aria-autocomplete=${ifDefined(ariaAutoCompleteValue)}
578-
aria-controls=${ifDefined(ariaControlsValue)}
579-
aria-describedby=${ifDefined(ariaDescribedByValue)}
580-
aria-expanded=${ifDefined(ariaExpandedValue)}
581-
aria-invalid=${this.error}
582-
aria-label=${ifDefined(ariaLabelValue)}
583-
aria-labelledby=${ifDefined(ariaLabelledByValue)}
584-
?disabled=${this.disabled}
585-
max=${ifDefined(maxValue as unknown as number)}
586-
maxlength=${ifDefined(maxLengthValue)}
587-
min=${ifDefined(minValue as unknown as number)}
588-
minlength=${ifDefined(minLengthValue)}
589-
pattern=${ifDefined(patternValue)}
590-
placeholder=${ifDefined(placeholderValue)}
591-
role=${ifDefined(roleValue)}
592-
?readonly=${this.readOnly}
593-
?required=${this.required}
594-
step=${ifDefined(stepValue as unknown as number)}
595-
type=${this.type}
596-
.value=${live(this.getInputValue())}
597-
@change=${this.redispatchEvent}
598-
@input=${this.handleInput}
599-
@select=${this.redispatchEvent}
600-
>`;
578+
class="md3-text-field__input"
579+
style=${styleMap(style)}
580+
aria-activedescendant=${ifDefined(ariaActiveDescendantValue)}
581+
aria-autocomplete=${ifDefined(ariaAutoCompleteValue)}
582+
aria-controls=${ifDefined(ariaControlsValue)}
583+
aria-describedby=${ifDefined(ariaDescribedByValue)}
584+
aria-expanded=${ifDefined(ariaExpandedValue)}
585+
aria-invalid=${this.error}
586+
aria-label=${ifDefined(ariaLabelValue)}
587+
aria-labelledby=${ifDefined(ariaLabelledByValue)}
588+
?disabled=${this.disabled}
589+
max=${ifDefined(maxValue as unknown as number)}
590+
maxlength=${ifDefined(maxLengthValue)}
591+
min=${ifDefined(minValue as unknown as number)}
592+
minlength=${ifDefined(minLengthValue)}
593+
pattern=${ifDefined(patternValue)}
594+
placeholder=${ifDefined(placeholderValue)}
595+
role=${ifDefined(roleValue)}
596+
?readonly=${this.readOnly}
597+
?required=${this.required}
598+
step=${ifDefined(stepValue as unknown as number)}
599+
type=${this.type}
600+
.value=${live(this.getInputValue())}
601+
@change=${this.redispatchEvent}
602+
@input=${this.handleInput}
603+
@select=${this.redispatchEvent}
604+
>`;
601605
}
602606

603607
/** @soyTemplate */
604608
protected getInputValue(): string {
605609
return this.dirty ? this.value : this.value || this.defaultValue;
606610
}
607611

612+
/** @soyTemplate */
613+
protected getAriaDescribedBy(): string {
614+
const hasSupport = !!this.getSupportingText();
615+
const hasCounter = this.hasCounter();
616+
// TODO(b/244609052): remove parens
617+
return (hasSupport || hasCounter) ?
618+
`${hasSupport ? this.supportingTextId : ''} ${
619+
hasCounter ? this.counterId : ''}` :
620+
'';
621+
}
622+
608623
/** @soyTemplate */
609624
protected renderPrefix(): TemplateResult {
610625
return this.prefixText ?
@@ -642,6 +657,31 @@ export abstract class TextField extends LitElement {
642657
return this.error && this.errorText ? this.errorText : this.supportingText;
643658
}
644659

660+
/**
661+
* @soyTemplate
662+
* @slotName supporting-text-end
663+
*/
664+
protected renderCounter(): TemplateResult {
665+
const counter = html`<span id=${this.counterId}
666+
class="md3-text-field__counter"
667+
slot="supporting-text-end">${this.getCounterText()}</span>`;
668+
// TODO(b/244473435): add aria-label and announcements
669+
return this.hasCounter() ? counter : html``;
670+
}
671+
672+
// TODO(b/244197198): replace with !!this.getCounterText()
673+
/** @soyTemplate */
674+
protected hasCounter(): boolean {
675+
return this.maxLength > -1;
676+
}
677+
678+
/** @soyTemplate */
679+
protected getCounterText(): TemplateResult {
680+
// TODO(b/244197198): replace with string return
681+
const length = this.value.length;
682+
return this.hasCounter() ? html`${length} / ${this.maxLength}` : html``;
683+
}
684+
645685
protected override updated() {
646686
// If a property such as `type` changes and causes the internal <input>
647687
// value to change without dispatching an event, re-sync it.

0 commit comments

Comments
 (0)