@h-k-dev/angular-email-editor 0.0.1 → 0.0.2

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.
@@ -1,10 +1,38 @@
1
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';
2
+ import { Directive, inject, TemplateRef, model, input, signal, computed, numberAttribute, booleanAttribute, output, contentChild, viewChild, ElementRef, Injector, viewChildren, afterNextRender, Component } from '@angular/core';
3
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';
4
+ import { AddressChipRemove, AddressChip, ADDRESS_DRAG_TYPE } from '@h-k-dev/angular-email-editor/address-chip';
5
5
  import { NgTemplateOutlet } from '@angular/common';
6
6
  import { validate } from '@angular/forms/signals';
7
7
 
8
+ /**
9
+ * Scopes chip drag and drop to the address inputs inside it: they trade
10
+ * chips with each other and refuse anyone else's — a writer's From, To, Cc
11
+ * and Bcc, and not the rows of another writer on the same page. Put it on
12
+ * whatever holds the rows:
13
+ *
14
+ * <form emailAddressDragGroup [formRoot]="envelope">
15
+ * <div email-address-input [formField]="envelope.from" limit="1"></div>
16
+ * <div email-address-input [formField]="envelope.to"></div>
17
+ * …
18
+ * </form>
19
+ *
20
+ * The nearest group around an input is its group. Inputs in no group make
21
+ * up one group of their own, so a page with a single writer needs none.
22
+ * Behaviour only: it renders nothing and sets no attribute.
23
+ *
24
+ * A chip lands in an address input or nowhere — it carries no plain text,
25
+ * so a subject line, an editor or any other field leaves it alone.
26
+ */
27
+ class AddressDragGroup {
28
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: AddressDragGroup, deps: [], target: i0.ɵɵFactoryTarget.Directive });
29
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.6", type: AddressDragGroup, isStandalone: true, selector: "[emailAddressDragGroup]", exportAs: ["emailAddressDragGroup"], ngImport: i0 });
30
+ }
31
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: AddressDragGroup, decorators: [{
32
+ type: Directive,
33
+ args: [{ selector: '[emailAddressDragGroup]', exportAs: 'emailAddressDragGroup' }]
34
+ }] });
35
+
8
36
  /**
9
37
  * The address input's label slot — opt in. By default the control owns no
10
38
  * label: the template that holds the form draws the row and the label beside
@@ -32,6 +60,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
32
60
  }] });
33
61
 
34
62
  let nextId = 0;
63
+ /** The chip drag in flight from an address input on this page: where it
64
+ came from, and whether an address input took it as a move. The drag
65
+ data can't be read until the drop, and `dropEffect` at the drag's end is
66
+ not to be trusted (WebKit reports `all`): this is how a target refuses
67
+ a duplicate before the drop and how the source learns to let go. */
68
+ let drag = null;
69
+ /** The entry a chip on this page last put on the clipboard — by a hold, by
70
+ Ctrl+C or by Ctrl+X. A hold writes plain text only (the async clipboard
71
+ takes no type of ours), so a paste of exactly this text is known to be a
72
+ chip's, and comes back as one. */
73
+ let clipboardEntry = null;
35
74
  /**
36
75
  * An address field the way Gmail's works: committed addresses as chips,
37
76
  * free typing after them. Enter, comma, semicolon or a paste commits what
@@ -68,7 +107,9 @@ let nextId = 0;
68
107
  * the caret at the start of the typing, ← puts a highlight on the last chip
69
108
  * and walks back along the list; → walks forward, and past the last chip
70
109
  * returns to the caret. Enter or F2 — or a double click on
71
- * any chip — edits it,
110
+ * any chip — edits it, Alt+← and Alt+→ move it a place along the list,
111
+ * Ctrl+C (Cmd+C) copies it, as a hold does, and Ctrl+X cuts it — unless
112
+ * text is selected in the input, which copies as ever —
72
113
  * Backspace or Delete removes it, Escape drops the highlight, and so does
73
114
  * typing. A click on a chip highlights it. Reaching a chip either way is
74
115
  * being done with the typing: an address in the input becomes a chip first,
@@ -120,6 +161,32 @@ let nextId = 0;
120
161
  * the input, and the selection with it — focus leaving the control clears
121
162
  * the selection.
122
163
  *
164
+ * **Chips move between inputs by drag and drop.** A chip of an editable
165
+ * input can be dragged to another — To into Cc, as Gmail and Outlook do —
166
+ * and leaves its own list when the other takes it. The other commits it
167
+ * as it was, flagged or pending alike. A chip whose address the other
168
+ * already holds is absorbed, as Gmail does: it leaves its list and the
169
+ * other keeps its own entry. A full list (From holds one) refuses the
170
+ * drop, and the chip stays. Dropped in its own list, a chip is reordered:
171
+ * it moves to the line; the line is not drawn where the chip already is. Alt (Option), or Ctrl, makes
172
+ * it a copy — onto a duplicate, a copy is nothing, so refused. Nothing
173
+ * moves while a drag is over a target: a thin line between its chips
174
+ * (`drop-marker`) shows where the chip will land, before or after the chip
175
+ * under the pointer, and the drop puts it there; the host carries
176
+ * `data-dropping` meanwhile. Only chips are places: a draft — typed in the
177
+ * input, or left at the end of the list when the row lost focus — is not,
178
+ * and a drop lands before it.
179
+ * A drop counts as working both fields: each is touched.
180
+ * A chip lands in an address input or nowhere: it carries no plain text,
181
+ * so a subject line or an editor can't take it. `emailAddressDragGroup` on
182
+ * the element around a writer's rows keeps its chips among them. Plain text
183
+ * dropped from elsewhere is a paste — the addresses become chips, the rest
184
+ * is left typed.
185
+ * `moveTo()` is the same move for the keyboard — a host's "Move to Cc" —
186
+ * and the moved chip says so (`movedLabel`). A touch is never a drag: on
187
+ * a phone the press is the chip's hold-to-copy. `allowDrag` turns it off;
188
+ * a disabled or read-only input is neither source nor target.
189
+ *
123
190
  * **The chips' remove control is a template slot.** An
124
191
  * `ng-template[emailAddressChipRemove]` in the input's content is handed to
125
192
  * every chip it renders, in place of the default × button.
@@ -174,6 +241,14 @@ class AddressInput {
174
241
  /** The chips' note on a placeholder a template fills in later. */
175
242
  pendingLabel = input('filled in when sent', /* @ts-ignore */
176
243
  ...(ngDevMode ? [{ debugName: "pendingLabel" }] : /* istanbul ignore next */ []));
244
+ /** What a chip moved into this input says, before its address — a host
245
+ with labelled rows says where: "Moved to Cc:". */
246
+ movedLabel = input('Moved', /* @ts-ignore */
247
+ ...(ngDevMode ? [{ debugName: "movedLabel" }] : /* istanbul ignore next */ []));
248
+ /** Chips may be dragged out of this input and dropped into it. Set false
249
+ for a row that only shows; a disabled or read-only input never takes
250
+ part either way. */
251
+ allowDrag = input(true, { ...(ngDevMode ? { debugName: "allowDrag" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
177
252
  // ── the form contract: pushed in by the field directive ───────────────
178
253
  /** The input's `name` attribute; the directive derives one from the path. */
179
254
  name = input('', /* @ts-ignore */
@@ -202,7 +277,11 @@ class AddressInput {
202
277
  ...(ngDevMode ? [{ debugName: "removeSlot" }] : /* istanbul ignore next */ []));
203
278
  field = viewChild.required('field', /* @ts-ignore */
204
279
  ...(ngDevMode ? [{ debugName: "field" }] : /* istanbul ignore next */ []));
280
+ /** The list of chips, where a press on a draggable chip parks focus. */
281
+ chipList = viewChild.required('chipList', /* @ts-ignore */
282
+ ...(ngDevMode ? [{ debugName: "chipList" }] : /* istanbul ignore next */ []));
205
283
  #host = inject(ElementRef);
284
+ #injector = inject(Injector);
206
285
  /** The address rule in force — ours, or the host's (`provideAddressRules`):
207
286
  how typing splits, what is an address, what is one recipient. */
208
287
  #rules = inject(ADDRESS_RULES);
@@ -210,6 +289,15 @@ class AddressInput {
210
289
  template fills in later. Everything else is a typo to hand back. */
211
290
  #accepts = (raw) => isMailbox(raw, this.#rules) || isPending(raw, this.#rules);
212
291
  #key = (raw) => addressKey(raw, this.#rules);
292
+ /** The list has this address already — in any case, under any name. */
293
+ #holds = (raw) => this.#keys().has(this.#key(raw));
294
+ /** The list's addresses by key, parsed once a change — not on every
295
+ `dragover`. */
296
+ #keys = computed(() => new Set(this.value().map(this.#key)), /* @ts-ignore */
297
+ ...(ngDevMode ? [{ debugName: "#keys" }] : /* istanbul ignore next */ []));
298
+ /** The chips this input trades with: those of the nearest
299
+ `emailAddressDragGroup`, or of every input in none. */
300
+ #group = inject(AddressDragGroup, { optional: true });
213
301
  #focused = signal(false, /* @ts-ignore */
214
302
  ...(ngDevMode ? [{ debugName: "#focused" }] : /* istanbul ignore next */ []));
215
303
  /** Focus is somewhere in the control — the input or a chip's remove
@@ -266,6 +354,12 @@ class AddressInput {
266
354
  return value.slice(start);
267
355
  }, /* @ts-ignore */
268
356
  ...(ngDevMode ? [{ debugName: "#trailingMalformed" }] : /* istanbul ignore next */ []));
357
+ /** Where the draft starts in the list: the trailing malformed entries are
358
+ the typing the row was left with — what focus hands back to the input
359
+ — and no chip is placed among or after them. A drop, or `moveTo()`,
360
+ lands before. */
361
+ #draftStart = computed(() => this.value().length - this.#trailingMalformed().length, /* @ts-ignore */
362
+ ...(ngDevMode ? [{ debugName: "#draftStart" }] : /* istanbul ignore next */ []));
269
363
  /** Full, and nothing in it to fix: the input hides — the chips say
270
364
  everything. A full list ending in a typo keeps it, so focus can hand
271
365
  the typo back. */
@@ -275,6 +369,24 @@ class AddressInput {
275
369
  button, the input is out of play. */
276
370
  locked = computed(() => this.disabled() || this.readonly(), /* @ts-ignore */
277
371
  ...(ngDevMode ? [{ debugName: "locked" }] : /* istanbul ignore next */ []));
372
+ /** Chips can be dragged out of the list, and dropped into it. */
373
+ canDrag = computed(() => this.allowDrag() && !this.locked(), /* @ts-ignore */
374
+ ...(ngDevMode ? [{ debugName: "canDrag" }] : /* istanbul ignore next */ []));
375
+ /** Where a drag over the input would land: the index in the list, and
376
+ the line drawn there — in the chip list's own coordinates. Null while
377
+ no drag the input would take is over it. Drawn on top, out of the
378
+ flow, so nothing moves under the pointer. */
379
+ dropMarker = signal(null, { ...(ngDevMode ? { debugName: "dropMarker" } : /* istanbul ignore next */ {}),
380
+ // A drag sends `dragover` every few dozen milliseconds, moving or not:
381
+ // the same place again is no change, and renders nothing.
382
+ equal: (a, b) => a === b ||
383
+ (!!a && !!b && a.index === b.index && a.x === b.x && a.y === b.y && a.height === b.height) });
384
+ /** A drag the input would take is over it. */
385
+ dropping = computed(() => this.dropMarker() !== null, /* @ts-ignore */
386
+ ...(ngDevMode ? [{ debugName: "dropping" }] : /* istanbul ignore next */ []));
387
+ /** The press under way is a mouse's (or a pen's) on a draggable chip:
388
+ its default has to stay, or the browser never starts the drag. */
389
+ #dragPress = false;
278
390
  /** Invalid *and* touched — the moment the state becomes the user's business. */
279
391
  flagged = computed(() => this.invalid() && this.touched(), /* @ts-ignore */
280
392
  ...(ngDevMode ? [{ debugName: "flagged" }] : /* istanbul ignore next */ []));
@@ -356,7 +468,13 @@ class AddressInput {
356
468
  would move focus off again. */
357
469
  onPointerdown(event) {
358
470
  const input = this.field().nativeElement;
359
- if (event.target === input || this.locked() || this.closed())
471
+ const target = event.target;
472
+ this.#dragPress =
473
+ event.pointerType !== 'touch' &&
474
+ this.canDrag() &&
475
+ !!target.closest?.('[email-address-chip]') &&
476
+ !target.closest('[data-slot=trailing]');
477
+ if (target === input || this.locked() || this.closed())
360
478
  return;
361
479
  input.focus();
362
480
  }
@@ -364,10 +482,16 @@ class AddressInput {
364
482
  its button, the gap between — leaves the caret where it is. The
365
483
  browser would otherwise move focus to the body for the press and back
366
484
  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. */
485
+ reflow: a flash, and a blur that commits and touches for nothing.
486
+ Except a mouse's press on a draggable chip: no engine starts a drag
487
+ from a press whose default was declined, so it keeps it, and the
488
+ press's focus lands on the chip list instead (`tabindex="-1"`) —
489
+ still inside the control, so nothing commits; the click or the drag's
490
+ end brings the caret back. */
368
491
  keepCaret(event) {
369
- if (event.target !== this.field().nativeElement)
370
- event.preventDefault();
492
+ if (event.target === this.field().nativeElement || this.#dragPress)
493
+ return;
494
+ event.preventDefault();
371
495
  }
372
496
  /** A click on a chip — not on its remove control — picks it, and only
373
497
  that: a click on the picked chip picks it again. The caret is in the
@@ -392,6 +516,219 @@ class AddressInput {
392
516
  const chip = this.chips().find((candidate) => candidate.address() === address);
393
517
  return chip ? chip.copy() : false;
394
518
  }
519
+ /** Moves a chip — the picked one by default — to another address input:
520
+ the keyboard's way to do what a drag does, for a host's own "Move to
521
+ Cc". The target takes it as it is, to the end of its list — before
522
+ a draft the row was left with — and it
523
+ leaves this one; both are touched, and the moved chip says so
524
+ (`movedLabel`). One the target already holds is absorbed: it leaves
525
+ here, and the target keeps its own entry. A full target, or either
526
+ input locked, moves nothing. Returns whether it moved. */
527
+ moveTo(target, address = this.current()) {
528
+ if (address === null || target === this || this.locked() || target.locked())
529
+ return false;
530
+ if (!this.value().includes(address))
531
+ return false;
532
+ if (!target.#holds(address) && !target.#insert([address], target.#draftStart()).length) {
533
+ return false;
534
+ }
535
+ this.remove(address);
536
+ this.touch.emit();
537
+ target.touch.emit();
538
+ target.#announceMoved(address);
539
+ return true;
540
+ }
541
+ /** The chip that arrived by a move — or the entry that absorbed it —
542
+ says so, once it is drawn. */
543
+ #announceMoved(address) {
544
+ const key = this.#key(address);
545
+ afterNextRender(() => this.chips()
546
+ .find((chip) => this.#key(chip.address()) === key)
547
+ ?.announce(`${this.movedLabel()} ${address}`), { injector: this.#injector });
548
+ }
549
+ // ── drag and drop ─────────────────────────────────────────────────────
550
+ /** A chip of this list started a drag: remember it, so a target on the
551
+ page can judge the drop before it happens and tell this list to let
552
+ go after. */
553
+ onChipDragstart(address) {
554
+ drag = this.canDrag() ? { source: this, group: this.#group, address, moved: false } : null;
555
+ }
556
+ /** The drag ended: if an address input took the chip as a move, it
557
+ leaves — through `remove()`, as the × takes it — and the field is
558
+ touched. The press parked focus on the list; the caret comes back. */
559
+ onChipDragend() {
560
+ const ended = drag;
561
+ drag = null;
562
+ if (ended?.source === this && ended.moved) {
563
+ this.remove(ended.address);
564
+ this.touch.emit();
565
+ }
566
+ if (document.activeElement === this.chipList().nativeElement)
567
+ this.focus();
568
+ }
569
+ /** What a drop here would be: `move` or `copy` for a drag the input
570
+ takes, `none` for one it refuses — said, so the text input inside
571
+ can't take the text in its place — and null for a drag that isn't an
572
+ address (files: the page's drop zone's), left to the page. A chip is
573
+ taken only from another input of the same group, while this page's
574
+ drag is in flight — a chip from anywhere else is refused. */
575
+ #dropEffect(event) {
576
+ const types = event.dataTransfer?.types ?? [];
577
+ const chip = types.includes(ADDRESS_DRAG_TYPE);
578
+ if (!chip && !types.includes('text/plain'))
579
+ return null;
580
+ if (!this.canDrag())
581
+ return chip ? 'none' : null;
582
+ if (!chip)
583
+ return this.full() ? 'none' : 'copy';
584
+ const from = drag;
585
+ if (!from || from.group !== this.#group)
586
+ return 'none';
587
+ const copy = event.altKey || event.ctrlKey;
588
+ // Its own list: a move is a reorder; a copy would add nothing.
589
+ if (from.source === this)
590
+ return copy ? 'none' : 'move';
591
+ // Held already: a move is absorbed, even into a full list; a copy is
592
+ // nothing.
593
+ if (this.#holds(from.address))
594
+ return copy ? 'none' : 'move';
595
+ if (this.full())
596
+ return 'none';
597
+ return copy ? 'copy' : 'move';
598
+ }
599
+ onDragover(event) {
600
+ const effect = this.#dropEffect(event);
601
+ if (effect === null)
602
+ return;
603
+ event.preventDefault();
604
+ event.dataTransfer.dropEffect = effect;
605
+ const marker = effect === 'none' ? null : this.#dropAt(event);
606
+ this.dropMarker.set(marker && this.#inPlace(marker.index) ? null : marker);
607
+ }
608
+ /** A drop at this index would leave the dragged chip where it is: right
609
+ before or right after itself, in its own list. No line there. */
610
+ #inPlace(index) {
611
+ if (drag?.source !== this)
612
+ return false;
613
+ const at = this.value().indexOf(drag.address);
614
+ return index === at || index === at + 1;
615
+ }
616
+ /** Moves a chip of the list to stand before the entry at `at` (the end,
617
+ past the last). True when it moved — not when it would stay put. */
618
+ #reorder(address, at) {
619
+ const from = this.value().indexOf(address);
620
+ if (from < 0 || at < 0 || at > this.value().length || at === from || at === from + 1) {
621
+ return false;
622
+ }
623
+ const to = at > from ? at - 1 : at;
624
+ this.value.update((current) => {
625
+ const next = current.filter((held) => held !== address);
626
+ next.splice(to, 0, address);
627
+ return next;
628
+ });
629
+ return true;
630
+ }
631
+ /** Where a drop at the pointer lands, in reading order: before the first
632
+ entry that starts after the pointer — on a later line, or on its line
633
+ past the pointer's side of the entry's middle — else at the end. And
634
+ the line that says so: midway between two entries on one line, or
635
+ just outside the entry at a line's start or end. */
636
+ #dropAt(event) {
637
+ const list = this.chipList().nativeElement;
638
+ const box = list.getBoundingClientRect();
639
+ const rtl = getComputedStyle(list).direction === 'rtl';
640
+ const x = event.clientX ?? 0;
641
+ const y = event.clientY ?? 0;
642
+ const chips = [...list.querySelectorAll(':scope > [email-address-chip]')];
643
+ // Only the chips before the draft are places; the draft is not.
644
+ const rects = chips.slice(0, this.#draftStart()).map((chip) => chip.getBoundingClientRect());
645
+ let index = rects.findIndex((r) => y < r.top || (y <= r.bottom && (rtl ? x > r.left + r.width / 2 : x < r.left + r.width / 2)));
646
+ if (index < 0)
647
+ index = rects.length;
648
+ const start = (r) => (rtl ? r.right : r.left);
649
+ const end = (r) => (rtl ? r.left : r.right);
650
+ // At a line's start or end the line stands a gap off the entry, as Gmail's does.
651
+ const out = rtl ? 4 : -4;
652
+ const at = rects[index];
653
+ const before = rects[index - 1];
654
+ let line;
655
+ if (at && before && Math.abs(before.top - at.top) < 1) {
656
+ line = { x: (end(before) + start(at)) / 2, top: at.top, height: at.height };
657
+ }
658
+ else if (at) {
659
+ line = { x: start(at) + out, top: at.top, height: at.height };
660
+ }
661
+ else if (before) {
662
+ line = { x: end(before) - out, top: before.top, height: before.height };
663
+ }
664
+ else {
665
+ // No chip yet: at the start — of the draft, if the row was left with one.
666
+ const first = (chips[0] ?? list.querySelector('[data-slot=entry]')).getBoundingClientRect();
667
+ line = { x: start(first) + (chips[0] ? out : 0), top: first.top, height: first.height };
668
+ }
669
+ return { index, x: line.x - box.left, y: line.top - box.top, height: line.height };
670
+ }
671
+ /** Leaving for somewhere outside the control: the drag is no longer over
672
+ it. (A leave with nowhere named — some engines — ends it too; the
673
+ next `dragover` inside brings it back.) */
674
+ onDragleave(event) {
675
+ const to = event.relatedTarget;
676
+ if (!to || !this.#host.nativeElement.contains(to))
677
+ this.dropMarker.set(null);
678
+ }
679
+ /** The drop: a chip is committed as it was; plain text from elsewhere is
680
+ a paste — its addresses become chips, the rest joins the typing. What
681
+ was typed stays. A chip the list holds already is absorbed by a move:
682
+ nothing is added, and the source lets it go. Nothing added otherwise
683
+ (the limit) is no drop: the source keeps its chip. */
684
+ onDrop(event) {
685
+ const effect = this.#dropEffect(event);
686
+ this.dropMarker.set(null);
687
+ if (effect === null)
688
+ return;
689
+ event.preventDefault();
690
+ const transfer = event.dataTransfer;
691
+ if (effect === 'none') {
692
+ transfer.dropEffect = 'none';
693
+ return;
694
+ }
695
+ const entry = transfer.getData(ADDRESS_DRAG_TYPE);
696
+ const { index } = this.#dropAt(event);
697
+ let changed;
698
+ if (entry && drag?.source === this) {
699
+ // Its own chip: a reorder. The source is this list; nothing leaves.
700
+ changed = this.#reorder(entry, index);
701
+ if (changed)
702
+ this.active.set(null);
703
+ }
704
+ else if (entry) {
705
+ const absorbed = effect === 'move' && this.#holds(entry);
706
+ changed = absorbed || this.#insert([entry], index).length > 0;
707
+ if (changed && drag?.address === entry) {
708
+ drag.moved = effect === 'move';
709
+ if (drag.moved)
710
+ this.#announceMoved(entry);
711
+ }
712
+ }
713
+ else {
714
+ const tokens = this.#rules.split(transfer.getData('text/plain'));
715
+ changed =
716
+ this.#insert(tokens.filter((token) => this.#accepts(token)), index).length > 0;
717
+ const rest = tokens.filter((token) => !this.#accepts(token));
718
+ if (rest.length) {
719
+ const input = this.field().nativeElement;
720
+ this.#write(input, [input.value, ...rest].filter(Boolean).join(', '));
721
+ this.refused.set(true);
722
+ input.focus();
723
+ changed = true;
724
+ }
725
+ }
726
+ if (!changed) {
727
+ transfer.dropEffect = 'none';
728
+ return;
729
+ }
730
+ this.touch.emit();
731
+ }
395
732
  /** Picking a chip is being done with the typing: the addresses in it
396
733
  become chips first, as Enter would make them, and what is not an
397
734
  address stays behind, flagged. */
@@ -477,6 +814,20 @@ class AddressInput {
477
814
  to the caret. */
478
815
  #onActiveKey(event, active, input) {
479
816
  const chips = this.value();
817
+ if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
818
+ // Alt+← / Alt+→ move the chip a place — the drag's reorder, by key.
819
+ // At either end there is nowhere to go, and the key is still the
820
+ // chip's: it never walks the highlight instead.
821
+ if (this.locked())
822
+ return false;
823
+ const address = chips[active];
824
+ if (this.#reorder(address, event.key === 'ArrowLeft' ? active - 1 : active + 2)) {
825
+ this.active.set(this.value().indexOf(address));
826
+ this.touch.emit();
827
+ }
828
+ event.preventDefault();
829
+ return true;
830
+ }
480
831
  switch (event.key) {
481
832
  case 'ArrowLeft':
482
833
  this.active.set(Math.max(0, active - 1));
@@ -511,13 +862,68 @@ class AddressInput {
511
862
  }
512
863
  /** A paste of several addresses: the addresses become chips, the rest
513
864
  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. */
865
+ input, so a name with spaces can still be edited before it commits —
866
+ but a chip's own copy (Ctrl+C, Ctrl+X, a hold) comes back as a chip,
867
+ as it was. With a chip picked, what is pasted goes right after it. */
515
868
  onPaste(event, input) {
516
- const text = event.clipboardData?.getData('text') ?? '';
517
- if (this.#rules.split(text).length < 2)
869
+ if (this.locked())
870
+ return;
871
+ const data = event.clipboardData;
872
+ const text = data?.getData('text') ?? '';
873
+ const chip = [...(data?.types ?? [])].includes(ADDRESS_DRAG_TYPE);
874
+ const entry = (chip && data.getData(ADDRESS_DRAG_TYPE)) || (text === clipboardEntry ? text : '');
875
+ const active = this.active();
876
+ const at = active === null ? this.#draftStart() : active + 1;
877
+ if (entry) {
878
+ event.preventDefault();
879
+ this.#insert([entry], at);
880
+ this.active.set(null);
881
+ return;
882
+ }
883
+ const tokens = this.#rules.split(text);
884
+ if (tokens.length < 2 && active === null)
518
885
  return;
519
886
  event.preventDefault();
520
- this.#settle(input, `${input.value}, ${text}`);
887
+ if (active === null) {
888
+ this.#settle(input, `${input.value}, ${text}`);
889
+ return;
890
+ }
891
+ const addresses = tokens.filter((token) => this.#accepts(token));
892
+ this.#insert(addresses, at);
893
+ const rest = tokens.filter((token) => !this.#accepts(token));
894
+ if (rest.length) {
895
+ this.#write(input, [input.value, ...rest].filter(Boolean).join(', '));
896
+ this.refused.set(true);
897
+ }
898
+ this.active.set(null);
899
+ }
900
+ /** Ctrl+C or Ctrl+X (Cmd on a Mac, or the Edit menu) with a chip picked
901
+ and no text selected: the chip goes on the clipboard — its header
902
+ form, as text and as the library's own type, so a paste into an
903
+ address input brings it back as a chip — and shows and says so, as a
904
+ hold does. A cut then removes it, as the × would. */
905
+ onClipboard(event, input, cut) {
906
+ const address = this.current();
907
+ const data = event.clipboardData;
908
+ if (address === null || !data || input.selectionStart !== input.selectionEnd)
909
+ return;
910
+ if (cut && this.locked())
911
+ return;
912
+ event.preventDefault();
913
+ data.setData('text/plain', address);
914
+ data.setData(ADDRESS_DRAG_TYPE, address);
915
+ clipboardEntry = address;
916
+ if (cut) {
917
+ this.remove(address);
918
+ return;
919
+ }
920
+ this.chips()
921
+ .find((chip) => chip.address() === address)
922
+ ?.showCopied();
923
+ }
924
+ /** A chip's hold copied it: remembered, so a paste of it is a chip. */
925
+ onChipCopied(address) {
926
+ clipboardEntry = address;
521
927
  }
522
928
  /** Focus arrived in the input: the typos at the end of the list come back
523
929
  into it, to be fixed — unless the user is already typing there. They
@@ -539,6 +945,9 @@ class AddressInput {
539
945
  onBlur(event, input) {
540
946
  if (this.#within(event))
541
947
  return;
948
+ this.#leave(input);
949
+ }
950
+ #leave(input) {
542
951
  const tokens = this.#rules.split(input.value);
543
952
  this.#insert(tokens.filter((token) => this.#accepts(token)));
544
953
  this.#insert(tokens.filter((token) => !this.#accepts(token)));
@@ -549,6 +958,10 @@ class AddressInput {
549
958
  onFocusout(event) {
550
959
  if (this.#within(event))
551
960
  return;
961
+ // Focus parked on the chip list by a press that came to nothing leaves
962
+ // from there: leaving is leaving, wherever the caret had got to.
963
+ if (event.target === this.chipList().nativeElement)
964
+ this.#leave(this.field().nativeElement);
552
965
  this.#focused.set(false);
553
966
  this.active.set(null);
554
967
  }
@@ -559,25 +972,27 @@ class AddressInput {
559
972
  this.#write(input, tokens.filter((token) => !this.#accepts(token)).join(', '));
560
973
  this.refused.set(!!input.value);
561
974
  }
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) {
975
+ /** Adds the new addresses to the list — at the end, or before the entry
976
+ at `at` (a drop) — up to `limit`, each only if its address is not in
977
+ the list yet; the entry already there keeps its name. Returns what
978
+ was added. */
979
+ #insert(incoming, at = Infinity) {
980
+ const added = [];
566
981
  if (!incoming.length)
567
- return;
982
+ return added;
568
983
  this.value.update((current) => {
569
- const next = [...current];
570
- const seen = new Set(next.map(this.#key));
984
+ const seen = new Set(current.map(this.#key));
571
985
  for (const address of incoming) {
572
- if (next.length >= this.limit())
986
+ if (current.length + added.length >= this.limit())
573
987
  break;
574
988
  if (seen.has(this.#key(address)))
575
989
  continue;
576
990
  seen.add(this.#key(address));
577
- next.push(address);
991
+ added.push(address);
578
992
  }
579
- return next;
993
+ return [...current.slice(0, at), ...added, ...current.slice(at)];
580
994
  });
995
+ return added;
581
996
  }
582
997
  /** Puts text in the input — the one way it is written, so `text` follows. */
583
998
  #write(input, text) {
@@ -590,7 +1005,7 @@ class AddressInput {
590
1005
  return !!destination && this.#host.nativeElement.contains(destination);
591
1006
  }
592
1007
  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"] }] });
1008
+ 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 }, movedLabel: { classPropertyName: "movedLabel", publicName: "movedLabel", isSignal: true, isRequired: false, transformFunction: null }, allowDrag: { classPropertyName: "allowDrag", publicName: "allowDrag", 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)", "dragenter": "onDragover($event)", "dragover": "onDragover($event)", "dragleave": "onDragleave($event)", "drop": "onDrop($event)" }, properties: { "attr.aria-disabled": "disabled() || null", "attr.data-invalid": "flagged() || null", "attr.data-full": "full() || null", "attr.data-focused": "focused() || null", "attr.data-dropping": "dropping() || 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: "chipList", first: true, predicate: ["chipList"], 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<!-- Focusable by script and by a press only (tabindex -1, while chips can\n be dragged): a press on a draggable chip must keep its default for the\n drag to start, and its focus lands here \u2014 inside the control. -->\n<ul #chipList data-slot=\"chips\" role=\"list\" [attr.tabindex]=\"canDrag() ? -1 : null\">\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 [draggable]=\"canDrag()\"\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 (copied)=\"onChipCopied($event)\"\n (click)=\"onChipClick($event, $index, field)\"\n (dblclick)=\"onChipDblclick($event, address, field)\"\n (dragstart)=\"onChipDragstart(address)\"\n (dragend)=\"onChipDragend()\"\n ></li>\n }\n <!-- Where a drag over the control would land: a line between entries,\n drawn on top and out of the flow \u2014 nothing moves under the pointer. -->\n @if (dropMarker(); as marker) {\n <li\n data-slot=\"drop-marker\"\n aria-hidden=\"true\"\n [style.left.px]=\"marker.x\"\n [style.top.px]=\"marker.y\"\n [style.height.px]=\"marker.height\"\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 (copy)=\"onClipboard($event, field, false)\"\n (cut)=\"onClipboard($event, field, true)\"\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]){position:relative;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=chips]):focus{outline:none}:host :where([data-slot=drop-marker]){position:absolute;box-sizing:content-box;margin-top:calc(-1 * var(--email-address-input-drop-overhang, 4px));padding-block:var(--email-address-input-drop-overhang, 4px);width:var(--email-address-input-drop-width, 2px);margin-left:calc(var(--email-address-input-drop-width, 2px) / -2);border-radius:1px;background:var(--email-address-input-drop-color, var(--mat-sys-primary, light-dark(#0b57d0, #a8c7fa)));pointer-events: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", "draggable", "removeTemplate"], outputs: ["removed", "copied"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
594
1009
  }
595
1010
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: AddressInput, decorators: [{
596
1011
  type: Component,
@@ -599,13 +1014,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
599
1014
  '[attr.data-invalid]': 'flagged() || null',
600
1015
  '[attr.data-full]': 'full() || null',
601
1016
  '[attr.data-focused]': 'focused() || null',
1017
+ '[attr.data-dropping]': 'dropping() || null',
602
1018
  '(pointerdown)': 'onPointerdown($event)',
603
1019
  '(mousedown)': 'keepCaret($event)',
604
1020
  '(click)': 'focus()',
605
1021
  '(focusin)': 'onFocusin()',
606
1022
  '(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 }] }] } });
1023
+ '(dragenter)': 'onDragover($event)',
1024
+ '(dragover)': 'onDragover($event)',
1025
+ '(dragleave)': 'onDragleave($event)',
1026
+ '(drop)': 'onDrop($event)',
1027
+ }, 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<!-- Focusable by script and by a press only (tabindex -1, while chips can\n be dragged): a press on a draggable chip must keep its default for the\n drag to start, and its focus lands here \u2014 inside the control. -->\n<ul #chipList data-slot=\"chips\" role=\"list\" [attr.tabindex]=\"canDrag() ? -1 : null\">\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 [draggable]=\"canDrag()\"\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 (copied)=\"onChipCopied($event)\"\n (click)=\"onChipClick($event, $index, field)\"\n (dblclick)=\"onChipDblclick($event, address, field)\"\n (dragstart)=\"onChipDragstart(address)\"\n (dragend)=\"onChipDragend()\"\n ></li>\n }\n <!-- Where a drag over the control would land: a line between entries,\n drawn on top and out of the flow \u2014 nothing moves under the pointer. -->\n @if (dropMarker(); as marker) {\n <li\n data-slot=\"drop-marker\"\n aria-hidden=\"true\"\n [style.left.px]=\"marker.x\"\n [style.top.px]=\"marker.y\"\n [style.height.px]=\"marker.height\"\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 (copy)=\"onClipboard($event, field, false)\"\n (cut)=\"onClipboard($event, field, true)\"\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]){position:relative;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=chips]):focus{outline:none}:host :where([data-slot=drop-marker]){position:absolute;box-sizing:content-box;margin-top:calc(-1 * var(--email-address-input-drop-overhang, 4px));padding-block:var(--email-address-input-drop-overhang, 4px);width:var(--email-address-input-drop-width, 2px);margin-left:calc(var(--email-address-input-drop-width, 2px) / -2);border-radius:1px;background:var(--email-address-input-drop-color, var(--mat-sys-primary, light-dark(#0b57d0, #a8c7fa)));pointer-events: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"] }]
1028
+ }], 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 }] }], movedLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "movedLabel", required: false }] }], allowDrag: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowDrag", 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 }] }], chipList: [{ type: i0.ViewChild, args: ['chipList', { isSignal: true }] }], chips: [{ type: i0.ViewChildren, args: [i0.forwardRef(() => AddressChip), { isSignal: true }] }] } });
609
1029
 
610
1030
  /** The error kinds `addressList` reports, for a host that switches on them. */
611
1031
  const ADDRESS_LIST_EMPTY = 'addressList.empty';
@@ -682,5 +1102,5 @@ function rulesInForce() {
682
1102
  * Generated bundle index. Do not edit.
683
1103
  */
684
1104
 
685
- export { ADDRESS_LIST_EMPTY, ADDRESS_LIST_INVALID, ADDRESS_LIST_TOO_MANY, AddressInput, AddressInputLabel, addressList };
1105
+ export { ADDRESS_LIST_EMPTY, ADDRESS_LIST_INVALID, ADDRESS_LIST_TOO_MANY, AddressDragGroup, AddressInput, AddressInputLabel, addressList };
686
1106
  //# sourceMappingURL=h-k-dev-angular-email-editor-address-input.mjs.map