Skip to content

Commit f791738

Browse files
committed
refactor(textarea): Use SlotController and code clean-ups
1 parent e4a29c8 commit f791738

File tree

3 files changed

+96
-106
lines changed

3 files changed

+96
-106
lines changed

src/components/common/controllers/slot.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ function setSlots<const T extends readonly string[]>(...slots: T) {
188188

189189
export { addSlotController, DefaultSlot, setSlots };
190190
export type {
191+
InferSlotNames,
191192
SlotController,
192193
SlotQueryOptions,
193194
SlotChangeCallback,

src/components/textarea/textarea.ts

Lines changed: 93 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
1-
import { html, LitElement, nothing, type TemplateResult } from 'lit';
21
import {
3-
property,
4-
query,
5-
queryAssignedElements,
6-
queryAssignedNodes,
7-
} from 'lit/decorators.js';
2+
html,
3+
LitElement,
4+
nothing,
5+
type PropertyValues,
6+
type TemplateResult,
7+
} from 'lit';
8+
import { property, query } from 'lit/decorators.js';
9+
import { cache } from 'lit/directives/cache.js';
810
import { ifDefined } from 'lit/directives/if-defined.js';
911
import { live } from 'lit/directives/live.js';
10-
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
11-
12+
import { styleMap } from 'lit/directives/style-map.js';
1213
import { addThemingController } from '../../theming/theming-controller.js';
1314
import { createResizeObserverController } from '../common/controllers/resize-observer.js';
15+
import {
16+
addSlotController,
17+
type InferSlotNames,
18+
type SlotChangeCallbackParameters,
19+
setSlots,
20+
} from '../common/controllers/slot.js';
1421
import { shadowOptions } from '../common/decorators/shadow-options.js';
15-
import { watch } from '../common/decorators/watch.js';
1622
import { registerComponent } from '../common/definitions/register.js';
1723
import type { Constructor } from '../common/mixins/constructor.js';
1824
import { EventEmitterMixin } from '../common/mixins/event-emitter.js';
@@ -22,12 +28,7 @@ import {
2228
type FormValueOf,
2329
} from '../common/mixins/forms/form-value.js';
2430
import { partMap } from '../common/part-map.js';
25-
import {
26-
addSafeEventListener,
27-
asNumber,
28-
createCounter,
29-
isEmpty,
30-
} from '../common/util.js';
31+
import { addSafeEventListener, asNumber } from '../common/util.js';
3132
import type {
3233
RangeTextSelectMode,
3334
SelectionRangeDirection,
@@ -49,6 +50,18 @@ export interface IgcTextareaComponentEventMap {
4950
blur: FocusEvent;
5051
}
5152

53+
let nextId = 1;
54+
const Slots = setSlots(
55+
'prefix',
56+
'suffix',
57+
'helper-text',
58+
'value-missing',
59+
'too-long',
60+
'too-short',
61+
'custom-error',
62+
'invalid'
63+
);
64+
5265
/**
5366
* This element represents a multi-line plain-text editing control,
5467
* useful when you want to allow users to enter a sizeable amount of free-form text,
@@ -92,43 +105,25 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin(
92105

93106
//#region Private properties and state
94107

95-
private static readonly increment = createCounter();
108+
private readonly _inputId = `textarea-${nextId++}`;
96109

97110
private readonly _themes = addThemingController(this, all);
98111

112+
private readonly _slots = addSlotController(this, {
113+
slots: Slots,
114+
onChange: this._handleSlotChange,
115+
});
116+
117+
@query('textarea', true)
118+
private readonly _input!: HTMLTextAreaElement;
119+
99120
protected override get __validators() {
100121
return textAreaValidators;
101122
}
102123

103124
protected override readonly _formValue: FormValueOf<string> =
104125
createFormValueState(this, { initialValue: '' });
105126

106-
protected readonly _inputId = `textarea-${IgcTextareaComponent.increment()}`;
107-
108-
@queryAssignedNodes({ flatten: true })
109-
private readonly _projected!: Node[];
110-
111-
@queryAssignedElements({
112-
slot: 'prefix',
113-
selector: '[slot="prefix"]:not([hidden])',
114-
})
115-
protected readonly _prefixes!: HTMLElement[];
116-
117-
@queryAssignedElements({
118-
slot: 'suffix',
119-
selector: '[slot="suffix"]:not([hidden])',
120-
})
121-
protected readonly _suffixes!: HTMLElement[];
122-
123-
@query('textarea', true)
124-
private readonly _input!: HTMLTextAreaElement;
125-
126-
private get _resizeStyles(): StyleInfo {
127-
return {
128-
resize: this.resize === 'auto' ? 'none' : this.resize,
129-
};
130-
}
131-
132127
//#endregion
133128

134129
//#region Public properties and attributes
@@ -281,27 +276,6 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin(
281276

282277
//#endregion
283278

284-
//#region Watchers
285-
286-
@watch('value')
287-
protected async _valueChanged(): Promise<void> {
288-
await this.updateComplete;
289-
this._setAreaHeight();
290-
}
291-
292-
@watch('rows', { waitUntilFirstUpdate: true })
293-
@watch('resize', { waitUntilFirstUpdate: true })
294-
protected _setAreaHeight(): void {
295-
if (this.resize === 'auto') {
296-
this._input.style.height = 'auto';
297-
this._input.style.height = `${this._setAutoHeight()}px`;
298-
} else {
299-
Object.assign(this._input.style, { height: undefined });
300-
}
301-
}
302-
303-
//#endregion
304-
305279
//#region Life-cycle hooks
306280

307281
constructor() {
@@ -315,27 +289,16 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin(
315289
addSafeEventListener(this, 'blur', this._handleBlur);
316290
}
317291

318-
protected override createRenderRoot(): HTMLElement | DocumentFragment {
319-
const root = super.createRenderRoot();
320-
root.addEventListener('slotchange', (event) =>
321-
this._handleSlotChange(event)
322-
);
323-
return root;
292+
protected override updated(props: PropertyValues<this>): void {
293+
if (props.has('rows') || props.has('resize') || props.has('value')) {
294+
this._setAreaHeight();
295+
}
324296
}
325297

326298
//#endregion
327299

328300
//#region Internal methods
329301

330-
protected _resolvePartNames() {
331-
return {
332-
container: true,
333-
prefixed: this._prefixes.length > 0,
334-
suffixed: this._suffixes.length > 0,
335-
filled: !!this.value,
336-
};
337-
}
338-
339302
private _setAutoHeight(): number {
340303
const { borderTopWidth, borderBottomWidth } = getComputedStyle(this._input);
341304
return (
@@ -345,16 +308,38 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin(
345308
);
346309
}
347310

311+
protected _setAreaHeight(): void {
312+
if (this.resize === 'auto') {
313+
this._input.style.height = 'auto';
314+
this._input.style.height = `${this._setAutoHeight()}px`;
315+
} else {
316+
Object.assign(this._input.style, { height: undefined });
317+
}
318+
}
319+
320+
protected _resolvePartNames() {
321+
return {
322+
container: true,
323+
prefixed: this._slots.hasAssignedElements('prefix', {
324+
selector: ':not([hidden])',
325+
}),
326+
suffixed: this._slots.hasAssignedElements('suffix', {
327+
selector: ':not([hidden])',
328+
}),
329+
filled: !!this.value,
330+
};
331+
}
332+
348333
//#endregion
349334

350335
//#region Event handlers
351336

352-
protected _handleSlotChange({ target }: Event): void {
353-
const slot = target as HTMLSlotElement;
354-
355-
// Default slot used for declarative value projection
356-
if (!slot.name) {
357-
const value = this._projected
337+
private _handleSlotChange({
338+
isDefault,
339+
}: SlotChangeCallbackParameters<InferSlotNames<typeof Slots>>): void {
340+
if (isDefault) {
341+
const value = this._slots
342+
.getAssignedNodes('[default]', true)
358343
.map((node) => node.textContent?.trim())
359344
.filter((node) => Boolean(node))
360345
.join('\r\n');
@@ -363,8 +348,6 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin(
363348
this.value = value;
364349
}
365350
}
366-
367-
this.requestUpdate();
368351
}
369352

370353
protected _handleInput(): void {
@@ -428,18 +411,14 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin(
428411

429412
//#region Renderers
430413

431-
protected _renderPrefix() {
432-
return html`
433-
<div part="prefix" .hidden=${isEmpty(this._prefixes)}>
434-
<slot name="prefix"></slot>
435-
</div>
436-
`;
437-
}
414+
protected _renderSlot(name: InferSlotNames<typeof Slots>) {
415+
const isHidden = !this._slots.hasAssignedElements(name, {
416+
selector: ':not([hidden])',
417+
});
438418

439-
protected _renderSuffix() {
440419
return html`
441-
<div part="suffix" .hidden=${isEmpty(this._suffixes)}>
442-
<slot name="suffix"></slot>
420+
<div part=${name} ?hidden=${isHidden}>
421+
<slot name=${name}></slot>
443422
</div>
444423
`;
445424
}
@@ -458,7 +437,8 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin(
458437
return html`
459438
${this._renderLabel()}
460439
<div part=${partMap(this._resolvePartNames())}>
461-
${this._renderPrefix()} ${this._renderInput()} ${this._renderSuffix()}
440+
${this._renderSlot('prefix')} ${this._renderInput()}
441+
${this._renderSlot('suffix')}
462442
</div>
463443
${this._renderValidationContainer()}
464444
`;
@@ -473,23 +453,29 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin(
473453
placeholder: !!this.placeholder,
474454
})}
475455
>
476-
<div part="start">${this._renderPrefix()}</div>
456+
<div part="start">${this._renderSlot('prefix')}</div>
477457
${this._renderInput()}
478458
<div part="notch">${this._renderLabel()}</div>
479459
<div part="filler"></div>
480-
<div part="end">${this._renderSuffix()}</div>
460+
<div part="end">${this._renderSlot('suffix')}</div>
481461
</div>
482462
${this._renderValidationContainer()}
483463
`;
484464
}
485465

486466
protected _renderInput() {
467+
const describedBy = this._slots.hasAssignedElements('helper-text')
468+
? 'helper-text'
469+
: nothing;
470+
487471
return html`
488472
<slot style="display: none"></slot>
489473
<textarea
490474
id=${this.id || this._inputId}
491475
part="input"
492-
style=${styleMap(this._resizeStyles)}
476+
style=${styleMap({
477+
resize: this.resize === 'auto' ? 'none' : this.resize,
478+
})}
493479
@input=${this._handleInput}
494480
@change=${this._handleChange}
495481
placeholder=${ifDefined(this.placeholder)}
@@ -505,7 +491,8 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin(
505491
?disabled=${this.disabled}
506492
?required=${this.required}
507493
?readonly=${this.readOnly}
508-
aria-invalid=${this.invalid ? 'true' : 'false'}
494+
aria-invalid=${this.invalid}
495+
aria-describedby=${describedBy}
509496
></textarea>
510497
`;
511498
}
@@ -515,9 +502,11 @@ export default class IgcTextareaComponent extends FormAssociatedRequiredMixin(
515502
}
516503

517504
protected override render() {
518-
return this._themes.theme === 'material'
519-
? this._renderMaterial()
520-
: this._renderStandard();
505+
return cache(
506+
this._themes.theme === 'material'
507+
? this._renderMaterial()
508+
: this._renderStandard()
509+
);
521510
}
522511

523512
//#endregion

stories/dialog.stories.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const metadata: Meta<IgcDialogComponent> = {
2323
keepOpenOnEscape: {
2424
type: 'boolean',
2525
description:
26-
"Whether the dialog should be kept open when pressing the 'ESCAPE' button.",
26+
"Whether the dialog should be kept open when pressing the 'Escape' button.",
2727
control: 'boolean',
2828
table: { defaultValue: { summary: 'false' } },
2929
},
@@ -64,7 +64,7 @@ const metadata: Meta<IgcDialogComponent> = {
6464
export default metadata;
6565

6666
interface IgcDialogArgs {
67-
/** Whether the dialog should be kept open when pressing the 'ESCAPE' button. */
67+
/** Whether the dialog should be kept open when pressing the 'Escape' button. */
6868
keepOpenOnEscape: boolean;
6969
/** Whether the dialog should be closed when clicking outside of it. */
7070
closeOnOutsideClick: boolean;

0 commit comments

Comments
 (0)