@h-k-dev/angular-email-editor 0.0.0-stage → 0.0.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 (51) hide show
  1. package/README.md +127 -2
  2. package/fesm2022/h-k-dev-angular-email-editor-actions.mjs +179 -0
  3. package/fesm2022/h-k-dev-angular-email-editor-actions.mjs.map +1 -0
  4. package/fesm2022/h-k-dev-angular-email-editor-address-chip.mjs +285 -0
  5. package/fesm2022/h-k-dev-angular-email-editor-address-chip.mjs.map +1 -0
  6. package/fesm2022/h-k-dev-angular-email-editor-address-input.mjs +686 -0
  7. package/fesm2022/h-k-dev-angular-email-editor-address-input.mjs.map +1 -0
  8. package/fesm2022/h-k-dev-angular-email-editor-address.mjs +489 -0
  9. package/fesm2022/h-k-dev-angular-email-editor-address.mjs.map +1 -0
  10. package/fesm2022/h-k-dev-angular-email-editor-anchor.mjs +64 -0
  11. package/fesm2022/h-k-dev-angular-email-editor-anchor.mjs.map +1 -0
  12. package/fesm2022/h-k-dev-angular-email-editor-attachment-chip.mjs +387 -0
  13. package/fesm2022/h-k-dev-angular-email-editor-attachment-chip.mjs.map +1 -0
  14. package/fesm2022/h-k-dev-angular-email-editor-attachment-chips.mjs +80 -0
  15. package/fesm2022/h-k-dev-angular-email-editor-attachment-chips.mjs.map +1 -0
  16. package/fesm2022/h-k-dev-angular-email-editor-chat-input.mjs +346 -0
  17. package/fesm2022/h-k-dev-angular-email-editor-chat-input.mjs.map +1 -0
  18. package/fesm2022/h-k-dev-angular-email-editor-focus.mjs +68 -0
  19. package/fesm2022/h-k-dev-angular-email-editor-focus.mjs.map +1 -0
  20. package/fesm2022/h-k-dev-angular-email-editor-image.mjs +132 -0
  21. package/fesm2022/h-k-dev-angular-email-editor-image.mjs.map +1 -0
  22. package/fesm2022/h-k-dev-angular-email-editor-material.mjs +124 -0
  23. package/fesm2022/h-k-dev-angular-email-editor-material.mjs.map +1 -0
  24. package/fesm2022/h-k-dev-angular-email-editor-metrics.mjs +243 -0
  25. package/fesm2022/h-k-dev-angular-email-editor-metrics.mjs.map +1 -0
  26. package/fesm2022/h-k-dev-angular-email-editor-palette.mjs +87 -0
  27. package/fesm2022/h-k-dev-angular-email-editor-palette.mjs.map +1 -0
  28. package/fesm2022/h-k-dev-angular-email-editor-proposal.mjs +307 -0
  29. package/fesm2022/h-k-dev-angular-email-editor-proposal.mjs.map +1 -0
  30. package/fesm2022/h-k-dev-angular-email-editor-suggestion-menu.mjs +392 -0
  31. package/fesm2022/h-k-dev-angular-email-editor-suggestion-menu.mjs.map +1 -0
  32. package/fesm2022/h-k-dev-angular-email-editor.mjs +18818 -0
  33. package/fesm2022/h-k-dev-angular-email-editor.mjs.map +1 -0
  34. package/package.json +133 -3
  35. package/styles/_scroll.scss +101 -0
  36. package/types/h-k-dev-angular-email-editor-actions.d.ts +147 -0
  37. package/types/h-k-dev-angular-email-editor-address-chip.d.ts +193 -0
  38. package/types/h-k-dev-angular-email-editor-address-input.d.ts +328 -0
  39. package/types/h-k-dev-angular-email-editor-address.d.ts +125 -0
  40. package/types/h-k-dev-angular-email-editor-anchor.d.ts +44 -0
  41. package/types/h-k-dev-angular-email-editor-attachment-chip.d.ts +271 -0
  42. package/types/h-k-dev-angular-email-editor-attachment-chips.d.ts +48 -0
  43. package/types/h-k-dev-angular-email-editor-chat-input.d.ts +101 -0
  44. package/types/h-k-dev-angular-email-editor-focus.d.ts +40 -0
  45. package/types/h-k-dev-angular-email-editor-image.d.ts +66 -0
  46. package/types/h-k-dev-angular-email-editor-material.d.ts +65 -0
  47. package/types/h-k-dev-angular-email-editor-metrics.d.ts +110 -0
  48. package/types/h-k-dev-angular-email-editor-palette.d.ts +51 -0
  49. package/types/h-k-dev-angular-email-editor-proposal.d.ts +170 -0
  50. package/types/h-k-dev-angular-email-editor-suggestion-menu.d.ts +177 -0
  51. package/types/h-k-dev-angular-email-editor.d.ts +3959 -0
@@ -0,0 +1,686 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, TemplateRef, Directive, model, input, signal, computed, numberAttribute, booleanAttribute, output, contentChild, viewChild, ElementRef, viewChildren, Component } from '@angular/core';
3
+ import { ADDRESS_RULES, isMailbox, isPending, addressKey, separatesAt, defaultAddressRules } from '@h-k-dev/angular-email-editor/address';
4
+ import { AddressChipRemove, AddressChip } from '@h-k-dev/angular-email-editor/address-chip';
5
+ import { NgTemplateOutlet } from '@angular/common';
6
+ import { validate } from '@angular/forms/signals';
7
+
8
+ /**
9
+ * The address input's label slot — opt in. By default the control owns no
10
+ * label: the template that holds the form draws the row and the label beside
11
+ * it, as it does for a plain `<input>`. A host that wants the label *inside*
12
+ * the control — at the start of the chips, the chips wrapping under it, the
13
+ * whole control one box (a Material form field around it, as iusta core
14
+ * writes its rows) — hands the control a template:
15
+ *
16
+ * <div email-address-input [formField]="envelope.to">
17
+ * <ng-template emailAddressLabel>To</ng-template>
18
+ * </div>
19
+ *
20
+ * The control renders it first in its row, in a box of its own
21
+ * (`[data-slot=label]`), and names its text input by it — unless the host
22
+ * named the input itself (`aria-labelledby` / `aria-label`).
23
+ */
24
+ class AddressInputLabel {
25
+ template = inject(TemplateRef);
26
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: AddressInputLabel, deps: [], target: i0.ɵɵFactoryTarget.Directive });
27
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.6", type: AddressInputLabel, isStandalone: true, selector: "ng-template[emailAddressLabel]", ngImport: i0 });
28
+ }
29
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: AddressInputLabel, decorators: [{
30
+ type: Directive,
31
+ args: [{ selector: 'ng-template[emailAddressLabel]' }]
32
+ }] });
33
+
34
+ let nextId = 0;
35
+ /**
36
+ * An address field the way Gmail's works: committed addresses as chips,
37
+ * free typing after them. Enter, comma, semicolon or a paste commits what
38
+ * was typed — a comma or semicolon inside a quoted name or angle brackets is
39
+ * just typed, and no key commits while an IME is composing; Backspace on the empty input takes the chip before the caret
40
+ * back for editing; `limit` caps the count (From takes one) and hides the
41
+ * input once reached.
42
+ *
43
+ * **Only an address becomes a chip while typing.** Enter, comma, semicolon,
44
+ * Tab and a paste commit the addresses in what was typed and leave the rest —
45
+ * what is not an address yet — in the input, flagged, for the user to
46
+ * finish. Leaving the field commits everything, so the form sees the typo
47
+ * and flags it; and focus coming back hands the malformed entries at the
48
+ * end of the list straight back to the input, as plain typing again.
49
+ *
50
+ * **The input is always at the end, and editing happens there.** A chip
51
+ * taken back for editing — a double click, Enter on a picked chip,
52
+ * Backspace, `edit()` — leaves its place: its address goes into the input,
53
+ * after the last chip, exactly like a typo the field handed back — and what
54
+ * is committed from it joins the end of the list. There is one place where
55
+ * text is typed, and it never moves.
56
+ *
57
+ * **One pending edit at a time.** There is only ever one thing being fixed,
58
+ * and it is what the caret is on. Starting another edit — `edit()`, Enter
59
+ * on a picked chip, Backspace taking the chip before the caret back — ends
60
+ * the one pending: a typo left in the input and any chip that is not an
61
+ * address go.
62
+ *
63
+ * **Chips while focused, a line of text otherwise.** With focus elsewhere
64
+ * the addresses read as a comma-separated line — no surface, no buttons;
65
+ * focus in the input turns them into chips.
66
+ *
67
+ * **The arrow keys rove the chips; the caret stays in the input.** With
68
+ * the caret at the start of the typing, ← puts a highlight on the last chip
69
+ * and walks back along the list; → walks forward, and past the last chip
70
+ * returns to the caret. Enter or F2 — or a double click on
71
+ * any chip — edits it,
72
+ * Backspace or Delete removes it, Escape drops the highlight, and so does
73
+ * typing. A click on a chip highlights it. Reaching a chip either way is
74
+ * being done with the typing: an address in the input becomes a chip first,
75
+ * as Enter would make it. The highlight is
76
+ * `aria-activedescendant` on the input and `aria-current` on the chip, and the
77
+ * chips' remove buttons stay out of the tab order — Tab lands on the input —
78
+ * except when the field is full and has no input to land on.
79
+ *
80
+ * A display name is kept — `Ada Lovelace <ada@example.com>` is one chip
81
+ * that shows "Ada Lovelace" — and the value stays `string[]` in header
82
+ * form, ready for a `To:` line. Typed bare, `Ada Lovelace ada@example.com`
83
+ * becomes that same chip. An address is in the list once: another entry
84
+ * with the same address — in any case, under any name — is dropped.
85
+ *
86
+ * **The control, not the row.** By default it owns no label and draws no row: the
87
+ * template that holds the form puts it in a row of its own making, beside a
88
+ * label of its own making, exactly as it does for a plain `<input>` — and
89
+ * names the control through `aria-labelledby` (the label's id) or
90
+ * `aria-label`, which land on the text input inside, the element assistive
91
+ * tech reaches. Clicking anywhere on the control puts the caret in; a row
92
+ * that wants its label to do the same calls `focus()`.
93
+ *
94
+ * **A signal-forms control, optionally.** It implements `FormValueControl`
95
+ * for `string[]`: bind a field with `[formField]="envelope.to"` and the
96
+ * directive drives `value` both ways, pushes `invalid`, `touched`,
97
+ * `disabled`, `readonly`, `required` and `name` in, and hears `touch` when
98
+ * focus leaves the input. Without a form, `[(value)]` alone is the whole
99
+ * contract; the state inputs are optional and the chips still flag a
100
+ * malformed address on their own. The rules that go with it — `addressList`
101
+ * — live beside it, because the built-in `required` and `email` know
102
+ * nothing of an array of mailboxes.
103
+ *
104
+ * **No messages of its own.** The control reflects the field's state —
105
+ * invalid *and* touched, so nothing shouts before the user has left it —
106
+ * and a malformed address is flagged on its chip; wording what is wrong is
107
+ * the job of the component that owns the form, which has the errors.
108
+ *
109
+ * **Parts are `data-slot`s** — `chips`, `entry`, `input` — plus the chips'
110
+ * own; that is how the control is styled from outside: theme it through
111
+ * the `--email-*` tokens, and reach a part for a structural override with
112
+ * two attributes.
113
+ *
114
+ * **The selection and its commands are public.** A host's own controls —
115
+ * a bar over the writer, the way a phone's action bar sits over the app
116
+ * bar — work the picked chip through `current()` and `select()` for the
117
+ * state and `edit()`, `remove()` and `copy()` for the commands, each on the
118
+ * current chip unless given another. Such a bar's buttons must decline
119
+ * their mousedown (`(mousedown)="$event.preventDefault()"`): focus stays in
120
+ * the input, and the selection with it — focus leaving the control clears
121
+ * the selection.
122
+ *
123
+ * **The chips' remove control is a template slot.** An
124
+ * `ng-template[emailAddressChipRemove]` in the input's content is handed to
125
+ * every chip it renders, in place of the default × button.
126
+ *
127
+ * **State on the host.** `aria-disabled` while disabled; `data-invalid`
128
+ * while invalid and touched; `data-full` once `limit` is reached;
129
+ * `data-focused` while focus is anywhere in the control. The text input
130
+ * carries `data-refused` while it holds text a commit left behind.
131
+ *
132
+ * No Material, no icon font, no headless library. It holds the list it
133
+ * shows and nothing else: contacts, lookups and sending are the host's.
134
+ */
135
+ class AddressInput {
136
+ /** The committed addresses, in header form. Two-way; the form's value. */
137
+ value = model([], /* @ts-ignore */
138
+ ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
139
+ placeholder = input('', /* @ts-ignore */
140
+ ...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
141
+ /** The id of the row's label, passed through to the text input — the
142
+ host's label, wherever the host put it. */
143
+ ariaLabelledby = input(null, { ...(ngDevMode ? { debugName: "ariaLabelledby" } : /* istanbul ignore next */ {}), alias: 'aria-labelledby' });
144
+ /** A name for the text input when there is no visible label. */
145
+ ariaLabel = input(null, { ...(ngDevMode ? { debugName: "ariaLabel" } : /* istanbul ignore next */ {}), alias: 'aria-label' });
146
+ /** The ids of whatever describes the field — a hint, an error line —
147
+ passed through to the text input, where assistive tech reads them. */
148
+ ariaDescribedby = input(null, { ...(ngDevMode ? { debugName: "ariaDescribedby" } : /* istanbul ignore next */ {}), alias: 'aria-describedby' });
149
+ #frameIds = signal([], /* @ts-ignore */
150
+ ...(ngDevMode ? [{ debugName: "#frameIds" }] : /* istanbul ignore next */ []));
151
+ /** What the text input's `aria-describedby` says: the host's own ids and a
152
+ frame's, each once. */
153
+ describedBy = computed(() => {
154
+ const ids = new Set([...(this.ariaDescribedby()?.split(/\s+/) ?? []), ...this.#frameIds()]);
155
+ ids.delete('');
156
+ return ids.size ? [...ids].join(' ') : null;
157
+ }, /* @ts-ignore */
158
+ ...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
159
+ /** Adds the ids of a frame's own lines — a form field's hint and error —
160
+ to what describes the text input, beside the host's `aria-describedby`;
161
+ each call replaces the frame's previous ids. */
162
+ describe(ids) {
163
+ this.#frameIds.set(ids);
164
+ }
165
+ /** Most addresses the field takes; the input hides once reached. Named
166
+ `limit`, not `max`: the form contract keeps that name for the `max` rule. */
167
+ limit = input(Infinity, { ...(ngDevMode ? { debugName: "limit" } : /* istanbul ignore next */ {}), transform: numberAttribute });
168
+ /** Prefix for each chip's remove button — "Remove ada@example.com". */
169
+ removeLabel = input('Remove', /* @ts-ignore */
170
+ ...(ngDevMode ? [{ debugName: "removeLabel" }] : /* istanbul ignore next */ []));
171
+ /** What a malformed address is flagged as. */
172
+ invalidLabel = input('not an email address', /* @ts-ignore */
173
+ ...(ngDevMode ? [{ debugName: "invalidLabel" }] : /* istanbul ignore next */ []));
174
+ /** The chips' note on a placeholder a template fills in later. */
175
+ pendingLabel = input('filled in when sent', /* @ts-ignore */
176
+ ...(ngDevMode ? [{ debugName: "pendingLabel" }] : /* istanbul ignore next */ []));
177
+ // ── the form contract: pushed in by the field directive ───────────────
178
+ /** The input's `name` attribute; the directive derives one from the path. */
179
+ name = input('', /* @ts-ignore */
180
+ ...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
181
+ disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
182
+ readonly = input(false, { ...(ngDevMode ? { debugName: "readonly" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
183
+ required = input(false, { ...(ngDevMode ? { debugName: "required" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
184
+ invalid = input(false, { ...(ngDevMode ? { debugName: "invalid" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
185
+ touched = input(false, { ...(ngDevMode ? { debugName: "touched" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
186
+ /** Focus left the input. The directive marks the field touched on it. */
187
+ touch = output();
188
+ /** The text input's id, for a host label's `for`. */
189
+ id = `email-address-input-${nextId++}`;
190
+ /** The host's label template, if it opted into one — rendered first in
191
+ the row; the text input is named by it. */
192
+ labelSlot = contentChild(AddressInputLabel, /* @ts-ignore */
193
+ ...(ngDevMode ? [{ debugName: "labelSlot" }] : /* istanbul ignore next */ []));
194
+ /** The label box's id, for the text input's `aria-labelledby`. */
195
+ labelId = `${this.id}-label`;
196
+ /** What names the text input: the host's own `aria-labelledby`; else the
197
+ label slot — unless the host gave an `aria-label` instead. */
198
+ labelledBy = computed(() => this.ariaLabelledby() ?? (this.labelSlot() && !this.ariaLabel() ? this.labelId : null), /* @ts-ignore */
199
+ ...(ngDevMode ? [{ debugName: "labelledBy" }] : /* istanbul ignore next */ []));
200
+ /** The host's remove template, passed on to every chip. */
201
+ removeSlot = contentChild(AddressChipRemove, /* @ts-ignore */
202
+ ...(ngDevMode ? [{ debugName: "removeSlot" }] : /* istanbul ignore next */ []));
203
+ field = viewChild.required('field', /* @ts-ignore */
204
+ ...(ngDevMode ? [{ debugName: "field" }] : /* istanbul ignore next */ []));
205
+ #host = inject(ElementRef);
206
+ /** The address rule in force — ours, or the host's (`provideAddressRules`):
207
+ how typing splits, what is an address, what is one recipient. */
208
+ #rules = inject(ADDRESS_RULES);
209
+ /** What becomes a chip: a well-formed mailbox, or a placeholder a
210
+ template fills in later. Everything else is a typo to hand back. */
211
+ #accepts = (raw) => isMailbox(raw, this.#rules) || isPending(raw, this.#rules);
212
+ #key = (raw) => addressKey(raw, this.#rules);
213
+ #focused = signal(false, /* @ts-ignore */
214
+ ...(ngDevMode ? [{ debugName: "#focused" }] : /* istanbul ignore next */ []));
215
+ /** Focus is somewhere in the control — the input or a chip's remove
216
+ button. The chips are chips only then. Read-only; for a host's own
217
+ frame (a form field whose label floats on it). */
218
+ focused = this.#focused.asReadonly();
219
+ #text = signal('', /* @ts-ignore */
220
+ ...(ngDevMode ? [{ debugName: "#text" }] : /* istanbul ignore next */ []));
221
+ /** What is typed in the input and not committed yet — for a host's own
222
+ frame, where a field with typing in it is not empty. */
223
+ text = this.#text.asReadonly();
224
+ /** The input holds text a commit left behind: not an address yet. */
225
+ refused = signal(false, /* @ts-ignore */
226
+ ...(ngDevMode ? [{ debugName: "refused" }] : /* istanbul ignore next */ []));
227
+ /** The chip the arrow keys or a click picked, by index in the value — the
228
+ one Enter, Backspace and Delete act on while the caret stays in the
229
+ input. */
230
+ active = signal(null, /* @ts-ignore */
231
+ ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
232
+ /** The picked chip's address — the one the arrow keys or a click reached
233
+ and the commands act on — or null. Public, for a host's own controls. */
234
+ current = computed(() => {
235
+ const index = this.active();
236
+ return index === null ? null : (this.value()[index] ?? null);
237
+ }, /* @ts-ignore */
238
+ ...(ngDevMode ? [{ debugName: "current" }] : /* istanbul ignore next */ []));
239
+ /** Picks a chip by address, or none. */
240
+ select(address) {
241
+ const index = address === null ? -1 : this.value().indexOf(address);
242
+ this.active.set(index < 0 ? null : index);
243
+ }
244
+ /** The chips as rendered, for the commands that are the chip's own. */
245
+ chips = viewChildren(AddressChip, /* @ts-ignore */
246
+ ...(ngDevMode ? [{ debugName: "chips" }] : /* istanbul ignore next */ []));
247
+ /** The active chip's id, for the input's `aria-activedescendant`. */
248
+ activeId = computed(() => {
249
+ const index = this.active();
250
+ return index !== null && index < this.value().length ? this.chipId(index) : null;
251
+ }, /* @ts-ignore */
252
+ ...(ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []));
253
+ chipId(index) {
254
+ return `${this.id}-chip-${index}`;
255
+ }
256
+ /** The cap is reached: a further address is refused. */
257
+ full = computed(() => this.value().length >= this.limit(), /* @ts-ignore */
258
+ ...(ngDevMode ? [{ debugName: "full" }] : /* istanbul ignore next */ []));
259
+ /** The malformed addresses at the end of the list — what focus hands back
260
+ to the input. */
261
+ #trailingMalformed = computed(() => {
262
+ const value = this.value();
263
+ let start = value.length;
264
+ while (start > 0 && !this.#accepts(value[start - 1]))
265
+ start--;
266
+ return value.slice(start);
267
+ }, /* @ts-ignore */
268
+ ...(ngDevMode ? [{ debugName: "#trailingMalformed" }] : /* istanbul ignore next */ []));
269
+ /** Full, and nothing in it to fix: the input hides — the chips say
270
+ everything. A full list ending in a typo keeps it, so focus can hand
271
+ the typo back. */
272
+ closed = computed(() => this.full() && !this.#trailingMalformed().length, /* @ts-ignore */
273
+ ...(ngDevMode ? [{ debugName: "closed" }] : /* istanbul ignore next */ []));
274
+ /** Nothing may change: disabled or read-only. Chips lose their remove
275
+ button, the input is out of play. */
276
+ locked = computed(() => this.disabled() || this.readonly(), /* @ts-ignore */
277
+ ...(ngDevMode ? [{ debugName: "locked" }] : /* istanbul ignore next */ []));
278
+ /** Invalid *and* touched — the moment the state becomes the user's business. */
279
+ flagged = computed(() => this.invalid() && this.touched(), /* @ts-ignore */
280
+ ...(ngDevMode ? [{ debugName: "flagged" }] : /* istanbul ignore next */ []));
281
+ /** Splits a run into addresses and adds the new ones to the end of the
282
+ list, up to `limit`. Duplicates — by address, case-insensitively —
283
+ are dropped; a malformed one is kept and
284
+ flagged — this is the host's way in, and the host decides what it
285
+ holds. */
286
+ commit(raw) {
287
+ this.#insert(this.#rules.split(raw));
288
+ }
289
+ /** Drops one address — the picked chip's by default. Identity by string:
290
+ the list never holds the same header form twice, so this takes exactly
291
+ the chip that asked. */
292
+ remove(address = this.current()) {
293
+ if (this.locked() || address === null)
294
+ return;
295
+ this.active.set(null);
296
+ const index = this.value().indexOf(address);
297
+ if (index < 0)
298
+ return;
299
+ this.value.update((current) => current.filter((a) => a !== address));
300
+ }
301
+ /** A chip's remove control was used: the address goes, and the caret
302
+ comes back to the input — the focused button went with its chip, and
303
+ focus left on nothing would strand a keyboard user and read to a host
304
+ as the field being left. */
305
+ removeChip(address) {
306
+ this.remove(address);
307
+ this.focus();
308
+ }
309
+ /** Takes a chip — the picked one by default — back for editing: it
310
+ leaves the list and the input, at the end, holds its address, caret at
311
+ the end. The edit pending until now ends first. A locked list edits
312
+ nothing. */
313
+ edit(address = this.current(), input = this.field().nativeElement) {
314
+ if (this.locked() || address === null)
315
+ return;
316
+ this.active.set(null);
317
+ this.#endPending(input, address);
318
+ if (!this.value().includes(address))
319
+ return;
320
+ this.remove(address);
321
+ this.#write(input, address);
322
+ input.focus();
323
+ input.setSelectionRange(input.value.length, input.value.length);
324
+ }
325
+ /** Ends the pending edit so another can start: the addresses typed so far
326
+ become chips, the rest of the typing goes, and so
327
+ does every chip that is not an address — but `keep`, the one about to
328
+ be edited. */
329
+ #endPending(input, keep) {
330
+ this.#insert(this.#rules.split(input.value).filter((token) => this.#accepts(token)));
331
+ this.#write(input, '');
332
+ this.refused.set(false);
333
+ const value = this.value();
334
+ const kept = value.filter((address) => this.#accepts(address) || address === keep);
335
+ if (kept.length !== value.length)
336
+ this.value.set(kept);
337
+ }
338
+ /** Puts the caret in the input — the whole control is the field, and a
339
+ host's label row may forward its own clicks here. A full field has no
340
+ input to put it in: focus goes to the last chip's remove control. */
341
+ focus() {
342
+ if (this.locked())
343
+ return;
344
+ if (!this.closed()) {
345
+ this.field().nativeElement.focus();
346
+ return;
347
+ }
348
+ const controls = this.#host.nativeElement.querySelectorAll('[data-slot=trailing] :is(button, [href], input, [tabindex]:not([tabindex="-1"]))');
349
+ controls[controls.length - 1]?.focus();
350
+ }
351
+ /** A press anywhere on the control is the input's from that moment: it
352
+ takes focus at the press, not at the click after the release — so a
353
+ chip pressed in a line of text turns the line to chips at once, and a
354
+ hold on a chip runs with the document focused, which the clipboard
355
+ insists on. `keepCaret` then declines the press's own default, which
356
+ would move focus off again. */
357
+ onPointerdown(event) {
358
+ const input = this.field().nativeElement;
359
+ if (event.target === input || this.locked() || this.closed())
360
+ return;
361
+ input.focus();
362
+ }
363
+ /** A press anywhere on the control but the text input itself — a chip,
364
+ its button, the gap between — leaves the caret where it is. The
365
+ browser would otherwise move focus to the body for the press and back
366
+ on the click, and in that instant the row would drop to text and
367
+ reflow: a flash, and a blur that commits and touches for nothing. */
368
+ keepCaret(event) {
369
+ if (event.target !== this.field().nativeElement)
370
+ event.preventDefault();
371
+ }
372
+ /** A click on a chip — not on its remove control — picks it, and only
373
+ that: a click on the picked chip picks it again. The caret is in the
374
+ input either way. */
375
+ onChipClick(event, index, input) {
376
+ if (event.target.closest('[data-slot=trailing]') || this.locked())
377
+ return;
378
+ this.#pick(index, input);
379
+ input.focus();
380
+ }
381
+ /** A double click on a chip — not on its remove control — takes it back
382
+ for editing. */
383
+ onChipDblclick(event, address, input) {
384
+ if (event.target.closest('[data-slot=trailing]'))
385
+ return;
386
+ this.edit(address, input);
387
+ }
388
+ /** Copies a chip's address — the picked one by default — to the
389
+ clipboard, as a hold on the chip does; the chip shows and says so.
390
+ Resolves to whether the copy went. */
391
+ async copy(address = this.current()) {
392
+ const chip = this.chips().find((candidate) => candidate.address() === address);
393
+ return chip ? chip.copy() : false;
394
+ }
395
+ /** Picking a chip is being done with the typing: the addresses in it
396
+ become chips first, as Enter would make them, and what is not an
397
+ address stays behind, flagged. */
398
+ #pick(index, input) {
399
+ const address = this.value()[index];
400
+ this.#settle(input, input.value);
401
+ this.active.set(address === undefined ? null : this.value().indexOf(address));
402
+ }
403
+ onFocusin() {
404
+ this.#focused.set(true);
405
+ }
406
+ onInput(input) {
407
+ this.#text.set(input.value);
408
+ this.refused.set(false);
409
+ this.active.set(null);
410
+ }
411
+ onKeydown(event, input) {
412
+ // An IME's own Enter picks its candidate; nothing here may act on it.
413
+ if (event.isComposing)
414
+ return;
415
+ const active = this.active();
416
+ if (active !== null && this.#onActiveKey(event, active, input))
417
+ return;
418
+ switch (event.key) {
419
+ case 'ArrowLeft': {
420
+ // From the start of the typing, ← reaches the last chip — the
421
+ // typing becoming chips first, when it holds an address.
422
+ if (input.selectionStart !== 0 || input.selectionEnd !== 0 || this.locked())
423
+ return;
424
+ const typed = this.#rules.split(input.value).some((token) => this.#accepts(token));
425
+ if (typed)
426
+ this.#settle(input, input.value);
427
+ const last = this.value().length - 1;
428
+ if (typed || last >= 0)
429
+ event.preventDefault();
430
+ if (last >= 0)
431
+ this.active.set(last);
432
+ return;
433
+ }
434
+ case ',':
435
+ case ';':
436
+ // Inside a quoted name or angle brackets the character is the
437
+ // mailbox's own: `"Lovelace, Ada"`.
438
+ if (!separatesAt(input.value, input.selectionStart ?? input.value.length))
439
+ return;
440
+ event.preventDefault();
441
+ this.#settle(input, input.value);
442
+ return;
443
+ case 'Enter':
444
+ // Swallowed even with nothing typed: Enter in an address row must
445
+ // never submit the form around it. The subject line is where Enter
446
+ // sends.
447
+ event.preventDefault();
448
+ this.#settle(input, input.value);
449
+ return;
450
+ case 'Tab':
451
+ // Tab on an address commits it and keeps the caret here, ready for
452
+ // the next one — the list is not left until there is nothing more
453
+ // to commit. Tab on nothing, on a typo, or backwards leaves the
454
+ // field as ever, and leaving commits and flags what was typed.
455
+ if (event.shiftKey || !this.#rules.split(input.value).some((token) => this.#accepts(token))) {
456
+ return;
457
+ }
458
+ event.preventDefault();
459
+ this.#settle(input, input.value);
460
+ return;
461
+ case 'Backspace': {
462
+ // On nothing typed, the last chip comes back to be edited — the one
463
+ // pending edit.
464
+ if (input.value)
465
+ return;
466
+ const previous = this.value().at(-1);
467
+ if (previous === undefined || this.locked())
468
+ return;
469
+ event.preventDefault();
470
+ this.edit(previous, input);
471
+ return;
472
+ }
473
+ }
474
+ }
475
+ /** The keys that act on the highlighted chip. True when one did. The
476
+ arrows walk the chips: ← stops at the first, → past the last returns
477
+ to the caret. */
478
+ #onActiveKey(event, active, input) {
479
+ const chips = this.value();
480
+ switch (event.key) {
481
+ case 'ArrowLeft':
482
+ this.active.set(Math.max(0, active - 1));
483
+ break;
484
+ case 'ArrowRight':
485
+ if (active + 1 < chips.length) {
486
+ this.active.set(active + 1);
487
+ }
488
+ else {
489
+ this.active.set(null);
490
+ input.setSelectionRange(0, 0);
491
+ }
492
+ break;
493
+ case 'Escape':
494
+ this.active.set(null);
495
+ break;
496
+ case 'Enter':
497
+ case 'F2':
498
+ this.edit(chips[active], input);
499
+ break;
500
+ case 'Backspace':
501
+ case 'Delete':
502
+ if (this.locked())
503
+ return false;
504
+ this.remove(chips[active]);
505
+ break;
506
+ default:
507
+ return false;
508
+ }
509
+ event.preventDefault();
510
+ return true;
511
+ }
512
+ /** A paste of several addresses: the addresses become chips, the rest
513
+ joins what the input already holds. A single token is left to the
514
+ input, so a name with spaces can still be edited before it commits. */
515
+ onPaste(event, input) {
516
+ const text = event.clipboardData?.getData('text') ?? '';
517
+ if (this.#rules.split(text).length < 2)
518
+ return;
519
+ event.preventDefault();
520
+ this.#settle(input, `${input.value}, ${text}`);
521
+ }
522
+ /** Focus arrived in the input: the typos at the end of the list come back
523
+ into it, to be fixed — unless the user is already typing there. They
524
+ come back as plain typing, not flagged: the caret is on them, and the
525
+ next commit judges them again. */
526
+ onFocus(input) {
527
+ const typos = this.#trailingMalformed();
528
+ if (input.value || !typos.length || this.locked())
529
+ return;
530
+ this.value.update((current) => current.slice(0, current.length - typos.length));
531
+ this.#write(input, typos.join(', '));
532
+ input.setSelectionRange(input.value.length, input.value.length);
533
+ this.refused.set(false);
534
+ }
535
+ /** Leaving the field commits everything typed — the addresses, then a
536
+ typo at the end of the list, so the form sees it and the next focus
537
+ hands it back — and is the touch. Moving to a chip's remove button is
538
+ not leaving. */
539
+ onBlur(event, input) {
540
+ if (this.#within(event))
541
+ return;
542
+ const tokens = this.#rules.split(input.value);
543
+ this.#insert(tokens.filter((token) => this.#accepts(token)));
544
+ this.#insert(tokens.filter((token) => !this.#accepts(token)));
545
+ this.#write(input, '');
546
+ this.refused.set(false);
547
+ this.touch.emit();
548
+ }
549
+ onFocusout(event) {
550
+ if (this.#within(event))
551
+ return;
552
+ this.#focused.set(false);
553
+ this.active.set(null);
554
+ }
555
+ /** Commits the addresses in a run and leaves the rest in the input. */
556
+ #settle(input, raw) {
557
+ const tokens = this.#rules.split(raw);
558
+ this.#insert(tokens.filter((token) => this.#accepts(token)));
559
+ this.#write(input, tokens.filter((token) => !this.#accepts(token)).join(', '));
560
+ this.refused.set(!!input.value);
561
+ }
562
+ /** Adds the new addresses to the end of the list, up to `limit` — each
563
+ only if its address is not in the list yet; the entry already there
564
+ keeps its name. */
565
+ #insert(incoming) {
566
+ if (!incoming.length)
567
+ return;
568
+ this.value.update((current) => {
569
+ const next = [...current];
570
+ const seen = new Set(next.map(this.#key));
571
+ for (const address of incoming) {
572
+ if (next.length >= this.limit())
573
+ break;
574
+ if (seen.has(this.#key(address)))
575
+ continue;
576
+ seen.add(this.#key(address));
577
+ next.push(address);
578
+ }
579
+ return next;
580
+ });
581
+ }
582
+ /** Puts text in the input — the one way it is written, so `text` follows. */
583
+ #write(input, text) {
584
+ input.value = text;
585
+ this.#text.set(text);
586
+ }
587
+ /** Whether focus is moving to somewhere else inside the control. */
588
+ #within(event) {
589
+ const destination = event.relatedTarget;
590
+ return !!destination && this.#host.nativeElement.contains(destination);
591
+ }
592
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: AddressInput, deps: [], target: i0.ɵɵFactoryTarget.Component });
593
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: AddressInput, isStandalone: true, selector: "[email-address-input]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "aria-labelledby", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "aria-label", isSignal: true, isRequired: false, transformFunction: null }, ariaDescribedby: { classPropertyName: "ariaDescribedby", publicName: "aria-describedby", isSignal: true, isRequired: false, transformFunction: null }, limit: { classPropertyName: "limit", publicName: "limit", isSignal: true, isRequired: false, transformFunction: null }, removeLabel: { classPropertyName: "removeLabel", publicName: "removeLabel", isSignal: true, isRequired: false, transformFunction: null }, invalidLabel: { classPropertyName: "invalidLabel", publicName: "invalidLabel", isSignal: true, isRequired: false, transformFunction: null }, pendingLabel: { classPropertyName: "pendingLabel", publicName: "pendingLabel", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, touched: { classPropertyName: "touched", publicName: "touched", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", touch: "touch" }, host: { listeners: { "pointerdown": "onPointerdown($event)", "mousedown": "keepCaret($event)", "click": "focus()", "focusin": "onFocusin()", "focusout": "onFocusout($event)" }, properties: { "attr.aria-disabled": "disabled() || null", "attr.data-invalid": "flagged() || null", "attr.data-full": "full() || null", "attr.data-focused": "focused() || null" } }, queries: [{ propertyName: "labelSlot", first: true, predicate: AddressInputLabel, descendants: true, isSignal: true }, { propertyName: "removeSlot", first: true, predicate: AddressChipRemove, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "field", first: true, predicate: ["field"], descendants: true, isSignal: true }, { propertyName: "chips", predicate: AddressChip, descendants: true, isSignal: true }], ngImport: i0, template: "<!-- No label here: the row around this control names it, through\n aria-labelledby or aria-label, both passed down to the text input. -->\n<ul data-slot=\"chips\" role=\"list\">\n <!-- The opt-in label (emailAddressLabel): first in the row, so the chips\n wrap under it. Hidden from assistive tech as text \u2014 it names the\n text input instead, through aria-labelledby. -->\n @if (labelSlot(); as slot) {\n <li data-slot=\"label\" aria-hidden=\"true\" [id]=\"labelId\">\n <ng-container [ngTemplateOutlet]=\"slot.template\" />\n </li>\n }\n @for (address of value(); track address) {\n <li\n email-address-chip\n [id]=\"chipId($index)\"\n [address]=\"address\"\n [removable]=\"!locked()\"\n [inline]=\"!focused()\"\n [aria-current]=\"active() === $index\"\n [removeTabbable]=\"closed()\"\n [removeLabel]=\"removeLabel()\"\n [removeTemplate]=\"removeSlot()?.template ?? null\"\n [invalidLabel]=\"invalidLabel()\"\n [pendingLabel]=\"pendingLabel()\"\n [aria-disabled]=\"disabled()\"\n (removed)=\"removeChip(address)\"\n (click)=\"onChipClick($event, $index, field)\"\n (dblclick)=\"onChipDblclick($event, address, field)\"\n ></li>\n }\n <!-- The entry is a list item too, so the chips and the typing sit in one\n wrapping row \u2014 always after the last chip. Hidden once the field is full (From takes one address)\n with nothing to fix: the chips say everything, and a second address\n would only be refused. -->\n <li data-slot=\"entry\" [hidden]=\"closed()\">\n <input\n #field\n data-slot=\"input\"\n type=\"text\"\n inputmode=\"email\"\n autocomplete=\"off\"\n autocapitalize=\"off\"\n spellcheck=\"false\"\n [id]=\"id\"\n [attr.name]=\"name() || null\"\n [disabled]=\"disabled()\"\n [readOnly]=\"readonly()\"\n [placeholder]=\"value().length ? '' : placeholder()\"\n [attr.aria-labelledby]=\"labelledBy()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-required]=\"required() || null\"\n [attr.aria-invalid]=\"flagged() || refused() || null\"\n [attr.aria-activedescendant]=\"activeId()\"\n [attr.data-refused]=\"refused() || null\"\n (keydown)=\"onKeydown($event, field)\"\n (paste)=\"onPaste($event, field)\"\n (input)=\"onInput(field)\"\n (mousedown)=\"active.set(null)\"\n (focus)=\"onFocus(field)\"\n (blur)=\"onBlur($event, field)\"\n />\n </li>\n</ul>\n", styles: [":host{display:flex;min-width:0;color:var(--email-address-input-color, var(--mat-sys-on-surface, light-dark(#1f1f1f, #e3e3e3)));font-family:var(--email-address-input-font-family, var(--mat-sys-body-medium-font, system-ui, sans-serif));font-size:var(--email-address-input-font-size, var(--mat-sys-body-medium-size, .875rem));font-weight:var(--email-address-input-font-weight, var(--mat-sys-body-medium-weight, 400));line-height:var(--email-address-input-line-height, var(--mat-sys-body-medium-line-height, 1.25rem));letter-spacing:var(--email-address-input-letter-spacing, var(--mat-sys-body-medium-tracking, .016rem));cursor:text}:host([aria-disabled=true]){cursor:default;opacity:.6}:host :where([data-slot=chips]){flex:1 1 0;display:flex;flex-wrap:wrap;align-items:center;gap:var(--email-address-input-row-gap, 8px) var(--email-address-input-column-gap, 4px);min-width:0;margin:0;padding:0;list-style:none}:host :where([data-slot=label]){flex:none;margin-inline-end:var(--email-address-input-label-gap, 4px);color:var(--email-address-input-label-color, var(--mat-sys-on-surface-variant, light-dark(#444746, #c4c7c5)));line-height:var(--email-address-input-label-line-height, 24px);white-space:nowrap}:host(:not([data-focused])) :where([data-slot=chips]){column-gap:var(--email-address-input-inline-gap, .3em)}:host(:not([data-focused])) :where([email-address-chip]:has(+[email-address-chip])):after{content:\",\"}:host :where([data-slot=entry]){flex:1 1 8ch;min-width:8ch;display:flex}:host :where([data-slot=entry][hidden]){display:none}:host(:not([data-focused])) :where([data-slot=entry]){flex-basis:0;min-width:0}:host :where([data-slot=input]){flex:1 1 auto;width:100%;min-width:0;height:var(--email-address-chip-height, 24px);margin:0;padding:0;box-sizing:border-box;border:none;outline:none;background:none;color:inherit;font:inherit;letter-spacing:inherit;line-height:var(--email-address-chip-height, 24px)}:host :where([data-slot=input][data-refused]){color:var(--email-address-chip-invalid-color, var(--mat-sys-error, light-dark(#b3261e, #f2b8b5)))}:host :where([data-slot=input])::placeholder{color:var(--email-address-input-placeholder-color, var(--mat-sys-on-surface-variant, light-dark(#5f6368, #9aa0a6)))}\n"], dependencies: [{ kind: "component", type: AddressChip, selector: "[email-address-chip]", inputs: ["address", "removable", "inline", "aria-current", "removeTabbable", "removeLabel", "invalidLabel", "pendingLabel", "aria-disabled", "holdDelay", "copiedLabel", "removeTemplate"], outputs: ["removed", "copied"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
594
+ }
595
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: AddressInput, decorators: [{
596
+ type: Component,
597
+ args: [{ selector: '[email-address-input]', imports: [AddressChip, NgTemplateOutlet], host: {
598
+ '[attr.aria-disabled]': 'disabled() || null',
599
+ '[attr.data-invalid]': 'flagged() || null',
600
+ '[attr.data-full]': 'full() || null',
601
+ '[attr.data-focused]': 'focused() || null',
602
+ '(pointerdown)': 'onPointerdown($event)',
603
+ '(mousedown)': 'keepCaret($event)',
604
+ '(click)': 'focus()',
605
+ '(focusin)': 'onFocusin()',
606
+ '(focusout)': 'onFocusout($event)',
607
+ }, template: "<!-- No label here: the row around this control names it, through\n aria-labelledby or aria-label, both passed down to the text input. -->\n<ul data-slot=\"chips\" role=\"list\">\n <!-- The opt-in label (emailAddressLabel): first in the row, so the chips\n wrap under it. Hidden from assistive tech as text \u2014 it names the\n text input instead, through aria-labelledby. -->\n @if (labelSlot(); as slot) {\n <li data-slot=\"label\" aria-hidden=\"true\" [id]=\"labelId\">\n <ng-container [ngTemplateOutlet]=\"slot.template\" />\n </li>\n }\n @for (address of value(); track address) {\n <li\n email-address-chip\n [id]=\"chipId($index)\"\n [address]=\"address\"\n [removable]=\"!locked()\"\n [inline]=\"!focused()\"\n [aria-current]=\"active() === $index\"\n [removeTabbable]=\"closed()\"\n [removeLabel]=\"removeLabel()\"\n [removeTemplate]=\"removeSlot()?.template ?? null\"\n [invalidLabel]=\"invalidLabel()\"\n [pendingLabel]=\"pendingLabel()\"\n [aria-disabled]=\"disabled()\"\n (removed)=\"removeChip(address)\"\n (click)=\"onChipClick($event, $index, field)\"\n (dblclick)=\"onChipDblclick($event, address, field)\"\n ></li>\n }\n <!-- The entry is a list item too, so the chips and the typing sit in one\n wrapping row \u2014 always after the last chip. Hidden once the field is full (From takes one address)\n with nothing to fix: the chips say everything, and a second address\n would only be refused. -->\n <li data-slot=\"entry\" [hidden]=\"closed()\">\n <input\n #field\n data-slot=\"input\"\n type=\"text\"\n inputmode=\"email\"\n autocomplete=\"off\"\n autocapitalize=\"off\"\n spellcheck=\"false\"\n [id]=\"id\"\n [attr.name]=\"name() || null\"\n [disabled]=\"disabled()\"\n [readOnly]=\"readonly()\"\n [placeholder]=\"value().length ? '' : placeholder()\"\n [attr.aria-labelledby]=\"labelledBy()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-describedby]=\"describedBy()\"\n [attr.aria-required]=\"required() || null\"\n [attr.aria-invalid]=\"flagged() || refused() || null\"\n [attr.aria-activedescendant]=\"activeId()\"\n [attr.data-refused]=\"refused() || null\"\n (keydown)=\"onKeydown($event, field)\"\n (paste)=\"onPaste($event, field)\"\n (input)=\"onInput(field)\"\n (mousedown)=\"active.set(null)\"\n (focus)=\"onFocus(field)\"\n (blur)=\"onBlur($event, field)\"\n />\n </li>\n</ul>\n", styles: [":host{display:flex;min-width:0;color:var(--email-address-input-color, var(--mat-sys-on-surface, light-dark(#1f1f1f, #e3e3e3)));font-family:var(--email-address-input-font-family, var(--mat-sys-body-medium-font, system-ui, sans-serif));font-size:var(--email-address-input-font-size, var(--mat-sys-body-medium-size, .875rem));font-weight:var(--email-address-input-font-weight, var(--mat-sys-body-medium-weight, 400));line-height:var(--email-address-input-line-height, var(--mat-sys-body-medium-line-height, 1.25rem));letter-spacing:var(--email-address-input-letter-spacing, var(--mat-sys-body-medium-tracking, .016rem));cursor:text}:host([aria-disabled=true]){cursor:default;opacity:.6}:host :where([data-slot=chips]){flex:1 1 0;display:flex;flex-wrap:wrap;align-items:center;gap:var(--email-address-input-row-gap, 8px) var(--email-address-input-column-gap, 4px);min-width:0;margin:0;padding:0;list-style:none}:host :where([data-slot=label]){flex:none;margin-inline-end:var(--email-address-input-label-gap, 4px);color:var(--email-address-input-label-color, var(--mat-sys-on-surface-variant, light-dark(#444746, #c4c7c5)));line-height:var(--email-address-input-label-line-height, 24px);white-space:nowrap}:host(:not([data-focused])) :where([data-slot=chips]){column-gap:var(--email-address-input-inline-gap, .3em)}:host(:not([data-focused])) :where([email-address-chip]:has(+[email-address-chip])):after{content:\",\"}:host :where([data-slot=entry]){flex:1 1 8ch;min-width:8ch;display:flex}:host :where([data-slot=entry][hidden]){display:none}:host(:not([data-focused])) :where([data-slot=entry]){flex-basis:0;min-width:0}:host :where([data-slot=input]){flex:1 1 auto;width:100%;min-width:0;height:var(--email-address-chip-height, 24px);margin:0;padding:0;box-sizing:border-box;border:none;outline:none;background:none;color:inherit;font:inherit;letter-spacing:inherit;line-height:var(--email-address-chip-height, 24px)}:host :where([data-slot=input][data-refused]){color:var(--email-address-chip-invalid-color, var(--mat-sys-error, light-dark(#b3261e, #f2b8b5)))}:host :where([data-slot=input])::placeholder{color:var(--email-address-input-placeholder-color, var(--mat-sys-on-surface-variant, light-dark(#5f6368, #9aa0a6)))}\n"] }]
608
+ }], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], ariaLabelledby: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-labelledby", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-label", required: false }] }], ariaDescribedby: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-describedby", required: false }] }], limit: [{ type: i0.Input, args: [{ isSignal: true, alias: "limit", required: false }] }], removeLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "removeLabel", required: false }] }], invalidLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalidLabel", required: false }] }], pendingLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "pendingLabel", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], invalid: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalid", required: false }] }], touched: [{ type: i0.Input, args: [{ isSignal: true, alias: "touched", required: false }] }], touch: [{ type: i0.Output, args: ["touch"] }], labelSlot: [{ type: i0.ContentChild, args: [i0.forwardRef(() => AddressInputLabel), { isSignal: true }] }], removeSlot: [{ type: i0.ContentChild, args: [i0.forwardRef(() => AddressChipRemove), { isSignal: true }] }], field: [{ type: i0.ViewChild, args: ['field', { isSignal: true }] }], chips: [{ type: i0.ViewChildren, args: [i0.forwardRef(() => AddressChip), { isSignal: true }] }] } });
609
+
610
+ /** The error kinds `addressList` reports, for a host that switches on them. */
611
+ const ADDRESS_LIST_EMPTY = 'addressList.empty';
612
+ const ADDRESS_LIST_INVALID = 'addressList.invalid';
613
+ const ADDRESS_LIST_TOO_MANY = 'addressList.tooMany';
614
+ const MESSAGES = {
615
+ empty: (min) => (min === 1 ? 'Add at least one address' : `Add at least ${min} addresses`),
616
+ invalid: (offenders) => {
617
+ const [first, ...rest] = offenders;
618
+ if (!rest.length)
619
+ return `“${first}” is not an email address`;
620
+ return `“${first}” and ${rest.length} other${rest.length === 1 ? '' : 's'} are not email addresses`;
621
+ },
622
+ tooMany: (max) => (max === 1 ? 'Only one address here' : `At most ${max} addresses`),
623
+ };
624
+ /**
625
+ * The rule an address field needs and the built-ins cannot give it:
626
+ * `required` takes an empty array for a value, and `email` validates one
627
+ * string. This validates a `string[]` of mailboxes in header form — the
628
+ * address input's value — as a list: at least `min` of them (one, unless
629
+ * told otherwise), at most `max`, and every one a well-formed address, with
630
+ * the offenders named in the message so a chip can be found and fixed. A
631
+ * placeholder a template fills in later (`{{contact.email}}`, by the rule's
632
+ * `isPlaceholder`) is no offender — it counts toward `min` and `max`;
633
+ * whether it may still be sent unresolved is the host's call.
634
+ *
635
+ * ```ts
636
+ * const envelope = form(model, (p) => {
637
+ * addressList(p.from, { max: 1 });
638
+ * addressList(p.to);
639
+ * addressList(p.cc, { min: 0 });
640
+ * });
641
+ * ```
642
+ *
643
+ * Errors carry a `kind` from the `ADDRESS_LIST_*` constants and a `message`
644
+ * the input shows; pass `messages` to word or translate them.
645
+ */
646
+ function addressList(path, options = {}) {
647
+ const { min = 1, max = Infinity } = options;
648
+ const messages = { ...MESSAGES, ...options.messages };
649
+ const rules = options.rules ?? rulesInForce();
650
+ validate(path, ({ value }) => {
651
+ const addresses = value();
652
+ const errors = [];
653
+ if (addresses.length < min) {
654
+ errors.push({ kind: ADDRESS_LIST_EMPTY, message: messages.empty(min) });
655
+ }
656
+ const offenders = addresses.filter((address) => !isMailbox(address, rules) && !isPending(address, rules));
657
+ if (offenders.length) {
658
+ errors.push({ kind: ADDRESS_LIST_INVALID, message: messages.invalid(offenders) });
659
+ }
660
+ if (addresses.length > max) {
661
+ errors.push({ kind: ADDRESS_LIST_TOO_MANY, message: messages.tooMany(max) });
662
+ }
663
+ return errors.length ? errors : null;
664
+ });
665
+ }
666
+ /** The rule provided where the schema runs — the form's injection context,
667
+ when it was made in one — or ours. */
668
+ function rulesInForce() {
669
+ try {
670
+ return inject(ADDRESS_RULES);
671
+ }
672
+ catch {
673
+ return defaultAddressRules;
674
+ }
675
+ }
676
+
677
+ /*
678
+ * Public API Surface of @h-k-dev/angular-email-editor/address-input
679
+ */
680
+
681
+ /**
682
+ * Generated bundle index. Do not edit.
683
+ */
684
+
685
+ export { ADDRESS_LIST_EMPTY, ADDRESS_LIST_INVALID, ADDRESS_LIST_TOO_MANY, AddressInput, AddressInputLabel, addressList };
686
+ //# sourceMappingURL=h-k-dev-angular-email-editor-address-input.mjs.map