@m3e/chips 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +539 -0
  3. package/cem.config.mjs +16 -0
  4. package/demo/index.html +183 -0
  5. package/dist/css-custom-data.json +777 -0
  6. package/dist/custom-elements.json +3307 -0
  7. package/dist/html-custom-data.json +277 -0
  8. package/dist/index.js +1516 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/index.min.js +480 -0
  11. package/dist/index.min.js.map +1 -0
  12. package/dist/src/AssistChipElement.d.ts +82 -0
  13. package/dist/src/AssistChipElement.d.ts.map +1 -0
  14. package/dist/src/ChipElement.d.ts +86 -0
  15. package/dist/src/ChipElement.d.ts.map +1 -0
  16. package/dist/src/ChipSetElement.d.ts +43 -0
  17. package/dist/src/ChipSetElement.d.ts.map +1 -0
  18. package/dist/src/ChipVariant.d.ts +3 -0
  19. package/dist/src/ChipVariant.d.ts.map +1 -0
  20. package/dist/src/FilterChipElement.d.ts +93 -0
  21. package/dist/src/FilterChipElement.d.ts.map +1 -0
  22. package/dist/src/FilterChipSetElement.d.ts +78 -0
  23. package/dist/src/FilterChipSetElement.d.ts.map +1 -0
  24. package/dist/src/InputChipElement.d.ts +104 -0
  25. package/dist/src/InputChipElement.d.ts.map +1 -0
  26. package/dist/src/InputChipSetElement.d.ts +75 -0
  27. package/dist/src/InputChipSetElement.d.ts.map +1 -0
  28. package/dist/src/SuggestionChipElement.d.ts +83 -0
  29. package/dist/src/SuggestionChipElement.d.ts.map +1 -0
  30. package/dist/src/index.d.ts +10 -0
  31. package/dist/src/index.d.ts.map +1 -0
  32. package/eslint.config.mjs +13 -0
  33. package/package.json +55 -0
  34. package/rollup.config.js +32 -0
  35. package/src/AssistChipElement.ts +103 -0
  36. package/src/ChipElement.ts +336 -0
  37. package/src/ChipSetElement.ts +60 -0
  38. package/src/ChipVariant.ts +2 -0
  39. package/src/FilterChipElement.ts +254 -0
  40. package/src/FilterChipSetElement.ts +161 -0
  41. package/src/InputChipElement.ts +287 -0
  42. package/src/InputChipSetElement.ts +360 -0
  43. package/src/SuggestionChipElement.ts +104 -0
  44. package/src/index.ts +9 -0
  45. package/tsconfig.json +9 -0
@@ -0,0 +1,287 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
2
+ import { css, CSSResultGroup, html, nothing, PropertyValues } from "lit";
3
+ import { customElement, property, query } from "lit/decorators.js";
4
+ import { ifDefined } from "lit/directives/if-defined.js";
5
+
6
+ import {
7
+ DisabledInteractive,
8
+ Disabled,
9
+ EventAttribute,
10
+ Role,
11
+ AttachInternals,
12
+ DesignToken,
13
+ hasAssignedNodes,
14
+ } from "@m3e/core";
15
+
16
+ import { M3eIconButtonElement } from "@m3e/icon-button";
17
+
18
+ import { M3eChipElement } from "./ChipElement";
19
+
20
+ /**
21
+ * @summary
22
+ * A chip which represents a discrete piece of information entered by a user.
23
+ *
24
+ * @description
25
+ * The `m3e-input-chip` component represents an input chip, allowing users to enter, display,
26
+ * and manage discrete values such as tags or keywords. It supports expressive styling, accessibility,
27
+ * keyboard interaction, and appearance variants including `elevated` and `outlined`.
28
+ *
29
+ * @tag m3e-input-chip
30
+ *
31
+ * @slot - Renders the label of the chip.
32
+ * @slot avatar - Renders an avatar before the chip's label.
33
+ * @slot icon - Renders an icon before the chip's label.
34
+ * @slot remove-icon - Renders the icon for the button used to remove the chip.
35
+ *
36
+ * @attr disabled - Whether the element is disabled.
37
+ * @attr disabled-interactive - Whether the element is disabled and interactive.
38
+ * @attr removable - Whether the chip is removable.
39
+ * @attr remove-label - The accessible label given to the button used to remove the chip.
40
+ * @attr value - A string representing the value of the chip.
41
+ * @attr variant - The appearance variant of the chip.
42
+ *
43
+ * @fires remove - Emitted when the remove button is clicked or DELETE or BACKSPACE key is pressed.
44
+ *
45
+ * @cssprop --m3e-chip-container-shape - Border radius of the chip container.
46
+ * @cssprop --m3e-chip-container-height - Base height of the chip container before density adjustment.
47
+ * @cssprop --m3e-chip-label-text-font-size - Font size of the chip label text.
48
+ * @cssprop --m3e-chip-label-text-font-weight - Font weight of the chip label text.
49
+ * @cssprop --m3e-chip-label-text-line-height - Line height of the chip label text.
50
+ * @cssprop --m3e-chip-label-text-tracking - Letter spacing of the chip label text.
51
+ * @cssprop --m3e-chip-label-text-color - Label text color in default state.
52
+ * @cssprop --m3e-chip-icon-color - Icon color in default state.
53
+ * @cssprop --m3e-chip-icon-size - Font size of leading/trailing icons.
54
+ * @cssprop --m3e-chip-spacing - Horizontal gap between chip content elements.
55
+ * @cssprop --m3e-chip-padding-start - Default start padding when no icon is present.
56
+ * @cssprop --m3e-chip-padding-end - Default end padding when no trailing icon is present.
57
+ * @cssprop --m3e-chip-with-icon-padding-start - Start padding when leading icon is present.
58
+ * @cssprop --m3e-chip-with-icon-padding-end - End padding when trailing icon is present.
59
+ * @cssprop --m3e-chip-disabled-label-text-color - Base color for disabled label text.
60
+ * @cssprop --m3e-chip-disabled-label-text-opacity - Opacity applied to disabled label text.
61
+ * @cssprop --m3e-chip-disabled-icon-color - Base color for disabled icons.
62
+ * @cssprop --m3e-chip-disabled-icon-opacity - Opacity applied to disabled icons.
63
+ * @cssprop --m3e-elevated-chip-container-color - Background color for elevated variant.
64
+ * @cssprop --m3e-elevated-chip-elevation - Elevation level for elevated variant.
65
+ * @cssprop --m3e-elevated-chip-hover-elevation - Elevation level on hover.
66
+ * @cssprop --m3e-elevated-chip-disabled-container-color - Background color for disabled elevated variant.
67
+ * @cssprop --m3e-elevated-chip-disabled-container-opacity - Opacity applied to disabled elevated background.
68
+ * @cssprop --m3e-elevated-chip-disabled-elevation - Elevation level for disabled elevated variant.
69
+ * @cssprop --m3e-outlined-chip-outline-thickness - Outline thickness for outlined variant.
70
+ * @cssprop --m3e-outlined-chip-outline-color - Outline color for outlined variant.
71
+ * @cssprop --m3e-outlined-chip-disabled-outline-color - Outline color for disabled outlined variant.
72
+ * @cssprop --m3e-outlined-chip-disabled-outline-opacity - Opacity applied to disabled outline.
73
+ * @cssprop --m3e-chip-avatar-size - Font size of the avatar slot content.
74
+ * @cssprop --m3e-chip-disabled-avatar-opacity - Opacity applied to the avatar when disabled.
75
+ * @cssprop --m3e-chip-with-avatar-padding-start - Start padding when an avatar is present.
76
+ */
77
+ @customElement("m3e-input-chip")
78
+ export class M3eInputChipElement extends EventAttribute(
79
+ DisabledInteractive(Disabled(AttachInternals(Role(M3eChipElement, "row"), true))),
80
+ "remove"
81
+ ) {
82
+ /** The styles of the element. */
83
+ static override styles: CSSResultGroup = [
84
+ M3eChipElement.styles,
85
+ css`
86
+ .cell {
87
+ display: inline-flex;
88
+ align-items: center;
89
+ outline: none;
90
+ column-gap: var(--m3e-chip-spacing, 0.5rem);
91
+ min-width: 0;
92
+ }
93
+ .remove-button {
94
+ --m3e-icon-button-extra-small-container-height: 1.5rem;
95
+ --m3e-icon-button-extra-small-icon-size: var(--m3e-chip-icon-size, 1.125rem);
96
+ width: 1.5rem;
97
+ }
98
+ .remove-icon {
99
+ flex: none;
100
+ width: var(--m3e-chip-icon-size, 1.125rem);
101
+ height: var(--m3e-chip-icon-size, 1.125rem);
102
+ }
103
+ .touch {
104
+ top: calc(
105
+ 0px - calc(calc(3rem - calc(var(--m3e-chip-container-height, 2rem) + ${DesignToken.density.calc(-2)})) / 2)
106
+ );
107
+ }
108
+ .wrapper {
109
+ height: 100%;
110
+ overflow: visible;
111
+ min-width: 0;
112
+ }
113
+ ::slotted([slot="avatar"]) {
114
+ flex: none;
115
+ font-size: var(--m3e-chip-avatar-size, 1.5rem);
116
+ }
117
+ :host(:disabled) ::slotted([slot="avatar"]),
118
+ :host([disabled-interactive]) ::slotted([slot="avatar"]) {
119
+ opacity: var(--m3e-chip-disabled-avatar-opacity, 38%);
120
+ color: var(--m3e-chip-disabled-icon-color, ${DesignToken.color.onSurface});
121
+ }
122
+ :host(.-with-avatar) ::slotted([slot="icon"]) {
123
+ display: none;
124
+ }
125
+ :host(.-with-avatar) .wrapper {
126
+ padding-inline-start: var(--m3e-chip-with-avatar-padding-start, 0.25rem);
127
+ }
128
+ @media (forced-colors: active) {
129
+ :host(:disabled) ::slotted([slot="avatar"]),
130
+ :host([disabled-interactive]) ::slotted([slot="avatar"]) {
131
+ color: CanvasText;
132
+ }
133
+ }
134
+ `,
135
+ ];
136
+
137
+ /** A reference to the grid cell of the chip. */
138
+ @query(".cell") readonly cell!: HTMLSpanElement;
139
+
140
+ /** A reference to the button used to remove the chip. */
141
+ @query(".remove-button") readonly removeButton!: M3eIconButtonElement | null;
142
+
143
+ /**
144
+ * Whether the chip is removable.
145
+ * @default false
146
+ */
147
+ @property({ type: Boolean }) removable = false;
148
+
149
+ /**
150
+ * The accessible label given to the button used to remove the chip.
151
+ * @default "Remove"
152
+ */
153
+ @property({ attribute: "remove-label" }) removeLabel = "Remove";
154
+
155
+ /** @inheritdoc */
156
+ override connectedCallback(): void {
157
+ super.connectedCallback();
158
+ this.removeAttribute("tabindex");
159
+ }
160
+
161
+ /** @inheritdoc */
162
+ protected override update(changedProperties: PropertyValues<this>): void {
163
+ super.update(changedProperties);
164
+ this.removeAttribute("tabindex");
165
+
166
+ if (changedProperties.has("removable")) {
167
+ this.classList.toggle("-with-trailing-icon", this.removable);
168
+ }
169
+ }
170
+
171
+ /** @inheritdoc */
172
+ protected override render(): unknown {
173
+ return html`<div class="base">
174
+ <m3e-elevation
175
+ class="elevation"
176
+ for="cell"
177
+ ?disabled="${this.disabled || this.disabledInteractive}"
178
+ ></m3e-elevation>
179
+ <m3e-state-layer
180
+ class="state-layer"
181
+ for="cell"
182
+ ?disabled="${this.disabled || this.disabledInteractive}"
183
+ ></m3e-state-layer>
184
+ <m3e-focus-ring class="focus-ring" for="cell" ?disabled="${this.disabled}"></m3e-focus-ring>
185
+ <m3e-ripple class="ripple" for="cell" ?disabled="${this.disabled || this.disabledInteractive}"></m3e-ripple>
186
+ <div class="wrapper">
187
+ <div
188
+ id="cell"
189
+ class="cell"
190
+ role="gridcell"
191
+ tabindex="${ifDefined(this.disabled ? undefined : "-1")}"
192
+ @keydown="${this.#handleKeyDown}"
193
+ >
194
+ <slot name="avatar" @slotchange="${this.#handleAvatarSlotChange}"></slot>
195
+ ${this._renderIcon()}
196
+ <div class="label">${this._renderSlot()}</div>
197
+ <div class="touch" aria-hidden="true"></div>
198
+ </div>
199
+ ${this._renderTrailingIcon()}
200
+ </div>
201
+ </div>`;
202
+ }
203
+
204
+ /** @internal @inheritdoc */
205
+ protected override _renderTrailingIcon(): unknown {
206
+ return this.removable
207
+ ? html`<span role="gridcell" class="remove">
208
+ <m3e-icon-button
209
+ class="remove-button"
210
+ aria-label="${this.removeLabel}"
211
+ size="extra-small"
212
+ tabindex="-1"
213
+ ?disabled="${this.disabled}"
214
+ ?disabled-interactive="${this.disabledInteractive}"
215
+ @click="${this.#handleRemoveButtonClick}"
216
+ >
217
+ <slot name="remove-icon">
218
+ <svg class="remove-icon" viewBox="0 -960 960 960" fill="currentColor">
219
+ <path
220
+ d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"
221
+ />
222
+ </svg>
223
+ </slot>
224
+ </m3e-icon-button>
225
+ </span>`
226
+ : nothing;
227
+ }
228
+
229
+ /** @private */
230
+ #handleAvatarSlotChange(e: Event): void {
231
+ this.classList.toggle("-with-avatar", hasAssignedNodes(<HTMLSlotElement>e.target));
232
+ }
233
+
234
+ /** @private */
235
+ #handleRemoveButtonClick(e: Event): void {
236
+ e.stopPropagation();
237
+ this.dispatchEvent(new Event("remove"));
238
+ }
239
+
240
+ /** @private */
241
+ #handleKeyDown(e: KeyboardEvent): void {
242
+ if (this.removable) {
243
+ switch (e.key) {
244
+ case "Backspace":
245
+ case "Delete":
246
+ this.dispatchEvent(new Event("remove"));
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ }
252
+
253
+ interface M3eInputChipElementEventMap extends HTMLElementEventMap {
254
+ remove: Event;
255
+ }
256
+
257
+ export interface M3eInputChipElement {
258
+ addEventListener<K extends keyof M3eInputChipElementEventMap>(
259
+ type: K,
260
+ listener: (this: M3eInputChipElement, ev: M3eInputChipElementEventMap[K]) => void,
261
+ options?: boolean | AddEventListenerOptions
262
+ ): void;
263
+
264
+ addEventListener(
265
+ type: string,
266
+ listener: EventListenerOrEventListenerObject,
267
+ options?: boolean | AddEventListenerOptions
268
+ ): void;
269
+
270
+ removeEventListener<K extends keyof M3eInputChipElementEventMap>(
271
+ type: K,
272
+ listener: (this: M3eInputChipElement, ev: M3eInputChipElementEventMap[K]) => void,
273
+ options?: boolean | EventListenerOptions
274
+ ): void;
275
+
276
+ removeEventListener(
277
+ type: string,
278
+ listener: EventListenerOrEventListenerObject,
279
+ options?: boolean | EventListenerOptions
280
+ ): void;
281
+ }
282
+
283
+ declare global {
284
+ interface HTMLElementTagNameMap {
285
+ "m3e-input-chip": M3eInputChipElement;
286
+ }
287
+ }
@@ -0,0 +1,360 @@
1
+ import { css, CSSResultGroup, html, PropertyValues } from "lit";
2
+ import { customElement } from "lit/decorators.js";
3
+
4
+ import {
5
+ AttachInternals,
6
+ ConstraintValidation,
7
+ DesignToken,
8
+ Dirty,
9
+ Disabled,
10
+ FormAssociated,
11
+ formValue,
12
+ Required,
13
+ RequiredConstraintValidation,
14
+ Role,
15
+ Touched,
16
+ } from "@m3e/core";
17
+
18
+ import { ListKeyManager, ListManager } from "@m3e/core/a11y";
19
+ import { FormFieldControl } from "@m3e/form-field";
20
+
21
+ import { M3eChipSetElement } from "./ChipSetElement";
22
+ import { M3eInputChipElement } from "./InputChipElement";
23
+
24
+ /**
25
+ * @summary
26
+ * A container that transforms user input into a cohesive set of interactive chips, supporting entry, editing, and removal of discrete values.
27
+ *
28
+ * @description
29
+ * The `m3e-input-chip-set` component enables users to input, display, and manage a collection of discrete
30
+ * values as input chips. Designed for expressive, accessible forms, it supports keyboard navigation, validation,
31
+ * and seamless integration with form controls. This component is ideal for capturing user-generated tags,
32
+ * keywords, or selections in a visually consistent and interactive manner.
33
+ *
34
+ * @example
35
+ * The following example illustrates the use of the `m3e-input-chip-set` inside a `m3e-form-field`.
36
+ * In this example, the `input` slot specifies the `input` element used to add input chips and the
37
+ * field label's `for` attribute targets the `input` element to provide an accessible label.
38
+ * ```html
39
+ * <m3e-form-field>
40
+ * <label slot="label" for="keywords">Keywords</label>
41
+ * <m3e-input-chip-set aria-label="Enter keywords">
42
+ * <input id="keywords" slot="input" placeholder="New keyword..." />
43
+ * </m3e-input-chip-set>
44
+ * </m3e-form-field>
45
+ * ```
46
+ *
47
+ * @tag m3e-input-chip-set
48
+ *
49
+ * @slot - Renders the chips of the set.
50
+ * @slot input - Renders the input element used to add new chips to the set.
51
+ *
52
+ * @attr disabled - Whether the element is disabled.
53
+ * @attr name - The name that identifies the element when submitting the associated form.
54
+ * @attr required - Whether a value is required for the element.
55
+ * @attr vertical - Whether the element is oriented vertically.
56
+ *
57
+ * @fires change - Emitted when a chip is added to, or removed from, the set.
58
+ *
59
+ * @cssprop --m3e-chip-set-spacing - The spacing (gap) between chips in the set.
60
+ */
61
+ @customElement("m3e-input-chip-set")
62
+ export class M3eInputChipSetElement
63
+ extends RequiredConstraintValidation(
64
+ Required(
65
+ ConstraintValidation(Dirty(Touched(FormAssociated(Disabled(AttachInternals(Role(M3eChipSetElement, "grid")))))))
66
+ )
67
+ )
68
+ implements FormFieldControl
69
+ {
70
+ /** The styles of the element. */
71
+ static override styles: CSSResultGroup = [
72
+ M3eChipSetElement.styles,
73
+ css`
74
+ ::slotted([slot="input"]) {
75
+ outline: unset;
76
+ border: unset;
77
+ background-color: transparent;
78
+ box-shadow: none;
79
+ font-family: inherit;
80
+ font-size: inherit;
81
+ line-height: initial;
82
+ letter-spacing: inherit;
83
+ color: var(--_form-field-input-color, inherit);
84
+ flex: 1 1 auto;
85
+ min-width: 0;
86
+ padding: unset;
87
+ }
88
+ ::slotted(m3e-input-chip) {
89
+ min-width: 0;
90
+ }
91
+ ::slotted([slot="input"])::placeholder {
92
+ user-select: none;
93
+ color: currentColor;
94
+ transition: opacity ${DesignToken.motion.duration.extraLong1};
95
+ }
96
+ :host(:not(:focus-within)) ::slotted([slot="input"])::placeholder {
97
+ opacity: 0;
98
+ transition: 0s;
99
+ }
100
+ :host(:hover) ::slotted([slot="input"])::placeholder {
101
+ transition: 0s;
102
+ }
103
+ span[role="row"],
104
+ span[role="gridcell"] {
105
+ display: contents;
106
+ }
107
+ @media (prefers-reduced-motion) {
108
+ ::slotted([slot="input"])::placeholder {
109
+ transition: none !important;
110
+ }
111
+ }
112
+ `,
113
+ ];
114
+
115
+ /** @private */ readonly #inputChangeHandler = () => this.#handleInputChange();
116
+ /** @private */ readonly #inputKeyDownHandler = (e: KeyboardEvent) => this.#handleInputKeyDown(e);
117
+ /** @private */ readonly #focusHandler = () => this.#handleFocus();
118
+ /** @private */ readonly #focusInHandler = () => this.#handleFocusIn();
119
+ /** @private */ readonly #focusOutHandler = () => this.#handleFocusOut();
120
+ /** @private */ readonly #chipRemoveHandler = (e: Event) => this.#handleChipRemove(e);
121
+ /** @private */ readonly #chipClickHandler = (e: Event) => this.#handleChipClick(e);
122
+
123
+ /** @private */ readonly #listManager = new ListManager<M3eInputChipElement>();
124
+ /** @private */ readonly #listKeyManager = new ListKeyManager<HTMLElement>()
125
+ .onActiveItemChange(() => this.#listKeyManager.activeItem?.focus())
126
+ .withHomeAndEnd()
127
+ .withSkipPredicate((x) => !x.hasAttribute("tabindex"));
128
+
129
+ /** @private */ #ignoreInputChange = false;
130
+ /** @private */ #input: HTMLInputElement | null = null;
131
+ /** @private */ #tabindex = 0;
132
+
133
+ /** The chips of the set. */
134
+ get chips(): readonly M3eInputChipElement[] {
135
+ // NOTE: query is used instead of the internal list management due to
136
+ // validating required state on change to support form-field integration.
137
+ return [...this.querySelectorAll("m3e-input-chip")];
138
+ }
139
+
140
+ /** The selected values of the set. */
141
+ get value(): readonly string[] | null {
142
+ const values = this.chips.map((x) => x.value);
143
+ return values.length == 0 ? null : values;
144
+ }
145
+
146
+ /** @inheritdoc @internal */
147
+ override get [formValue]() {
148
+ const values = this.value;
149
+ if (!values) return null;
150
+ const data = new FormData();
151
+ for (const value of values) {
152
+ data.append(this.name, value);
153
+ }
154
+ return data;
155
+ }
156
+
157
+ /** @inheritdoc */
158
+ get shouldLabelFloat(): boolean {
159
+ return this.chips.length > 0;
160
+ }
161
+
162
+ /** @inheritdoc */
163
+ onContainerClick(): void {
164
+ this.#input?.focus();
165
+ }
166
+
167
+ /** @inheritdoc */
168
+ override connectedCallback(): void {
169
+ super.connectedCallback();
170
+
171
+ this.closest("m3e-form-field")?.notifyControlStateChange();
172
+
173
+ this.#tabindex = Number.parseInt(this.getAttribute("tabindex") ?? "0");
174
+ this.addEventListener("focus", this.#focusHandler);
175
+ this.addEventListener("focusin", this.#focusInHandler);
176
+ this.addEventListener("focusout", this.#focusOutHandler);
177
+ }
178
+
179
+ /** @inheritdoc */
180
+ override disconnectedCallback(): void {
181
+ super.disconnectedCallback();
182
+
183
+ this.removeEventListener("focus", this.#focusHandler);
184
+ this.removeEventListener("focusin", this.#focusInHandler);
185
+ this.removeEventListener("focusout", this.#focusOutHandler);
186
+ }
187
+
188
+ /** @inheritdoc */
189
+ protected override firstUpdated(_changedProperties: PropertyValues): void {
190
+ super.firstUpdated(_changedProperties);
191
+
192
+ if (!this.hasAttribute("tabindex")) {
193
+ this.setAttribute("tabindex", `${this.#tabindex}`);
194
+ }
195
+ }
196
+
197
+ /** @inheritdoc */
198
+ protected override update(changedProperties: PropertyValues<this>): void {
199
+ super.update(changedProperties);
200
+
201
+ if (changedProperties.has("vertical")) {
202
+ this.ariaOrientation = null;
203
+ }
204
+ if (changedProperties.has("disabled")) {
205
+ this.#listManager.items.forEach((x) => (x.disabled = this.disabled));
206
+ if (this.#input) {
207
+ this.#input.disabled = this.disabled;
208
+ }
209
+ }
210
+ }
211
+
212
+ /** @inheritdoc */
213
+ protected override render(): unknown {
214
+ return html`<slot @keydown="${this.#handleKeyDown}" @slotchange="${this.#handleSlotChange}"></slot>
215
+ <span role="row">
216
+ <span role="gridcell"><slot name="input" @slotchange="${this.#handleInputSlotChange}"></slot></span>
217
+ </span> `;
218
+ }
219
+
220
+ /** @private */
221
+ #handleKeyDown(e: KeyboardEvent): void {
222
+ this.#listKeyManager.onKeyDown(e);
223
+ }
224
+
225
+ /** @private */
226
+ async #handleSlotChange(): Promise<void> {
227
+ const { added, removed } = this.#listManager.setItems([...this.querySelectorAll("m3e-input-chip")]);
228
+
229
+ for (const chip of added) {
230
+ if (chip.isUpdatePending) {
231
+ await chip.updateComplete;
232
+ }
233
+ if (this.disabled) {
234
+ chip.disabled = true;
235
+ }
236
+ chip.addEventListener("remove", this.#chipRemoveHandler);
237
+ chip.cell.addEventListener("click", this.#chipClickHandler);
238
+ }
239
+
240
+ removed.forEach((x) => {
241
+ x.removeEventListener("remove", this.#chipRemoveHandler);
242
+ x.cell.removeEventListener("click", this.#chipClickHandler);
243
+ });
244
+
245
+ this.#listKeyManager.setItems(
246
+ this.#listManager.items.flatMap((x) => (x.removeButton ? [x.cell, x.removeButton] : [x.cell]))
247
+ );
248
+ if (!this.#listKeyManager.activeItem) {
249
+ this.#listKeyManager.updateActiveItem(this.#listKeyManager.items.find((x) => x.hasAttribute("tabindex")));
250
+ }
251
+ }
252
+
253
+ /** @private */
254
+ #handleInputSlotChange(): void {
255
+ const input = this.querySelector("input");
256
+ if (this.#input) {
257
+ this.#input.removeEventListener("change", this.#inputChangeHandler);
258
+ this.#input.removeEventListener("keydown", this.#inputKeyDownHandler);
259
+ }
260
+
261
+ this.#input = input;
262
+ if (this.#input) {
263
+ this.#input.disabled = this.disabled;
264
+ this.#input.addEventListener("change", this.#inputChangeHandler);
265
+ this.#input.addEventListener("keydown", this.#inputKeyDownHandler);
266
+
267
+ const property = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!;
268
+ Object.defineProperty(input, "value", {
269
+ get: () => property.get?.call(input),
270
+ set: (value: string) => {
271
+ property.set?.call(input, value);
272
+ if (this.#input === input && !this.#ignoreInputChange) {
273
+ this.#handleInputChange();
274
+ }
275
+ },
276
+ });
277
+ }
278
+ }
279
+
280
+ /** @private */
281
+ #handleFocus(): void {
282
+ setTimeout(() => (this.#listKeyManager.activeItem ?? this.#input)?.focus());
283
+ }
284
+
285
+ /** @private */
286
+ #handleFocusIn(): void {
287
+ this.setAttribute("tabindex", "-1");
288
+ }
289
+
290
+ /** @private */
291
+ #handleFocusOut(): void {
292
+ this.setAttribute("tabindex", `${this.#tabindex}`);
293
+ }
294
+
295
+ /** @private */
296
+ #handleChipRemove(e: Event): void {
297
+ const chip = <M3eInputChipElement>e.target;
298
+ const index = this.#listManager.items.indexOf(chip);
299
+ const nextChip = this.#listManager.items.find((x, y) => y > index && !x.disabled && x.removable);
300
+
301
+ chip.remove();
302
+
303
+ this.#listKeyManager.setActiveItem(this.#listKeyManager.items.find((x) => x === nextChip?.removeButton));
304
+ if (!this.#listKeyManager.activeItem) {
305
+ this.#input?.focus();
306
+ }
307
+
308
+ this.dispatchEvent(new Event("change", { bubbles: true }));
309
+ }
310
+
311
+ /** @private */
312
+ #handleChipClick(e: Event): void {
313
+ this.#listKeyManager.updateActiveItem(e.composedPath().find((x) => x instanceof M3eInputChipElement)?.cell);
314
+ }
315
+
316
+ /** @private */
317
+ #handleInputChange(): void {
318
+ const value = this.#input?.value;
319
+ if (!value) return;
320
+
321
+ setTimeout(() => {
322
+ const value = this.#input?.value;
323
+ if (!value) return;
324
+
325
+ const chip = document.createElement("m3e-input-chip");
326
+ chip.removable = true;
327
+ chip.appendChild(document.createTextNode(value));
328
+ this.appendChild(chip);
329
+
330
+ if (this.#input) {
331
+ try {
332
+ this.#ignoreInputChange = true;
333
+ this.#input.value = "";
334
+ } finally {
335
+ this.#ignoreInputChange = false;
336
+ }
337
+ }
338
+
339
+ this.dispatchEvent(new Event("change", { bubbles: true }));
340
+ });
341
+ }
342
+
343
+ /** @private */
344
+ #handleInputKeyDown(e: KeyboardEvent): void {
345
+ if (e.key === "Backspace" && !this.#input?.value) {
346
+ const item = [...this.#listManager.items]
347
+ .reverse()
348
+ .find((x) => !x.disabled && !x.disabledInteractive && x.removable);
349
+ if (item) {
350
+ item.dispatchEvent(new Event("remove"));
351
+ }
352
+ }
353
+ }
354
+ }
355
+
356
+ declare global {
357
+ interface HTMLElementTagNameMap {
358
+ "m3e-input-chip-set": M3eInputChipSetElement;
359
+ }
360
+ }