@cdevhub/ngx-tw 0.2.1 → 0.4.0
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.
- package/README.md +264 -49
- package/fesm2022/cdevhub-ngx-tw-accordion.mjs +11 -7
- package/fesm2022/cdevhub-ngx-tw-accordion.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-alert.mjs +43 -29
- package/fesm2022/cdevhub-ngx-tw-alert.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-aspect-ratio.mjs +103 -0
- package/fesm2022/cdevhub-ngx-tw-aspect-ratio.mjs.map +1 -0
- package/fesm2022/cdevhub-ngx-tw-avatar.mjs +56 -31
- package/fesm2022/cdevhub-ngx-tw-avatar.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-badge.mjs +61 -35
- package/fesm2022/cdevhub-ngx-tw-badge.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-breadcrumbs.mjs +154 -93
- package/fesm2022/cdevhub-ngx-tw-breadcrumbs.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-button.mjs +24 -15
- package/fesm2022/cdevhub-ngx-tw-button.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-calendar-luxon.mjs +3 -3
- package/fesm2022/cdevhub-ngx-tw-calendar-luxon.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-calendar.mjs +408 -215
- package/fesm2022/cdevhub-ngx-tw-calendar.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-card.mjs +33 -24
- package/fesm2022/cdevhub-ngx-tw-card.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-carousel.mjs +144 -79
- package/fesm2022/cdevhub-ngx-tw-carousel.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-checkbox.mjs +99 -47
- package/fesm2022/cdevhub-ngx-tw-checkbox.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-code-block.mjs +34 -20
- package/fesm2022/cdevhub-ngx-tw-code-block.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-collapsible.mjs +46 -29
- package/fesm2022/cdevhub-ngx-tw-collapsible.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-combobox.mjs +195 -110
- package/fesm2022/cdevhub-ngx-tw-combobox.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-command-palette.mjs +140 -73
- package/fesm2022/cdevhub-ngx-tw-command-palette.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-core.mjs +16 -11
- package/fesm2022/cdevhub-ngx-tw-core.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-date-picker.mjs +393 -149
- package/fesm2022/cdevhub-ngx-tw-date-picker.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-date-range-picker.mjs +305 -150
- package/fesm2022/cdevhub-ngx-tw-date-range-picker.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-dialog.mjs +56 -43
- package/fesm2022/cdevhub-ngx-tw-dialog.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-empty-state.mjs +51 -33
- package/fesm2022/cdevhub-ngx-tw-empty-state.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-file-upload.mjs +905 -0
- package/fesm2022/cdevhub-ngx-tw-file-upload.mjs.map +1 -0
- package/fesm2022/cdevhub-ngx-tw-flip-card.mjs +49 -26
- package/fesm2022/cdevhub-ngx-tw-flip-card.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-form-field.mjs +133 -74
- package/fesm2022/cdevhub-ngx-tw-form-field.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-icon-lucide.mjs +2 -2
- package/fesm2022/cdevhub-ngx-tw-icon-lucide.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-icon.mjs +28 -17
- package/fesm2022/cdevhub-ngx-tw-icon.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-input.mjs +37 -21
- package/fesm2022/cdevhub-ngx-tw-input.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-item.mjs +33 -24
- package/fesm2022/cdevhub-ngx-tw-item.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-menu.mjs +63 -48
- package/fesm2022/cdevhub-ngx-tw-menu.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-number-input.mjs +622 -0
- package/fesm2022/cdevhub-ngx-tw-number-input.mjs.map +1 -0
- package/fesm2022/cdevhub-ngx-tw-paginator.mjs +137 -71
- package/fesm2022/cdevhub-ngx-tw-paginator.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-popover.mjs +101 -46
- package/fesm2022/cdevhub-ngx-tw-popover.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-progress-bar.mjs +57 -30
- package/fesm2022/cdevhub-ngx-tw-progress-bar.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-radio.mjs +108 -57
- package/fesm2022/cdevhub-ngx-tw-radio.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-segmented-control.mjs +46 -26
- package/fesm2022/cdevhub-ngx-tw-segmented-control.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-select.mjs +217 -122
- package/fesm2022/cdevhub-ngx-tw-select.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-separator.mjs +21 -12
- package/fesm2022/cdevhub-ngx-tw-separator.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-sheet.mjs +58 -44
- package/fesm2022/cdevhub-ngx-tw-sheet.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-skeleton.mjs +25 -14
- package/fesm2022/cdevhub-ngx-tw-skeleton.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-slider.mjs +155 -79
- package/fesm2022/cdevhub-ngx-tw-slider.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-sort.mjs +44 -25
- package/fesm2022/cdevhub-ngx-tw-sort.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-spinner.mjs +29 -16
- package/fesm2022/cdevhub-ngx-tw-spinner.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-split.mjs +72 -42
- package/fesm2022/cdevhub-ngx-tw-split.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-stat.mjs +107 -64
- package/fesm2022/cdevhub-ngx-tw-stat.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-stepper.mjs +113 -47
- package/fesm2022/cdevhub-ngx-tw-stepper.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-switch.mjs +61 -32
- package/fesm2022/cdevhub-ngx-tw-switch.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-tab-nav.mjs +88 -42
- package/fesm2022/cdevhub-ngx-tw-tab-nav.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-table.mjs +266 -118
- package/fesm2022/cdevhub-ngx-tw-table.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-tabs.mjs +101 -51
- package/fesm2022/cdevhub-ngx-tw-tabs.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-tags-input.mjs +798 -0
- package/fesm2022/cdevhub-ngx-tw-tags-input.mjs.map +1 -0
- package/fesm2022/cdevhub-ngx-tw-textarea.mjs +11 -7
- package/fesm2022/cdevhub-ngx-tw-textarea.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-theme.mjs +22 -14
- package/fesm2022/cdevhub-ngx-tw-theme.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-time-picker.mjs +243 -95
- package/fesm2022/cdevhub-ngx-tw-time-picker.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-timeline.mjs +149 -82
- package/fesm2022/cdevhub-ngx-tw-timeline.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-toast.mjs +89 -55
- package/fesm2022/cdevhub-ngx-tw-toast.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-tooltip.mjs +50 -28
- package/fesm2022/cdevhub-ngx-tw-tooltip.mjs.map +1 -1
- package/fesm2022/cdevhub-ngx-tw-transfer.mjs +1129 -0
- package/fesm2022/cdevhub-ngx-tw-transfer.mjs.map +1 -0
- package/fesm2022/cdevhub-ngx-tw-tree.mjs +554 -0
- package/fesm2022/cdevhub-ngx-tw-tree.mjs.map +1 -0
- package/fesm2022/cdevhub-ngx-tw.mjs +7 -0
- package/fesm2022/cdevhub-ngx-tw.mjs.map +1 -1
- package/index.json +1 -0
- package/package.json +45 -20
- package/theme/index.css +10 -2
- package/types/cdevhub-ngx-tw-aspect-ratio.d.ts +40 -0
- package/types/cdevhub-ngx-tw-calendar.d.ts +39 -8
- package/types/cdevhub-ngx-tw-command-palette.d.ts +7 -0
- package/types/cdevhub-ngx-tw-date-picker.d.ts +36 -10
- package/types/cdevhub-ngx-tw-file-upload.d.ts +250 -0
- package/types/cdevhub-ngx-tw-form-field.d.ts +22 -3
- package/types/cdevhub-ngx-tw-icon-lucide.d.ts +21 -3
- package/types/cdevhub-ngx-tw-number-input.d.ts +130 -0
- package/types/cdevhub-ngx-tw-popover.d.ts +8 -0
- package/types/cdevhub-ngx-tw-stepper.d.ts +30 -2
- package/types/cdevhub-ngx-tw-tab-nav.d.ts +8 -0
- package/types/cdevhub-ngx-tw-table.d.ts +30 -1
- package/types/cdevhub-ngx-tw-tabs.d.ts +8 -0
- package/types/cdevhub-ngx-tw-tags-input.d.ts +217 -0
- package/types/cdevhub-ngx-tw-time-picker.d.ts +31 -6
- package/types/cdevhub-ngx-tw-transfer.d.ts +330 -0
- package/types/cdevhub-ngx-tw-tree.d.ts +189 -0
- package/types/cdevhub-ngx-tw.d.ts +7 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { input, output, inject, ElementRef, DestroyRef, viewChild, viewChildren, signal, computed, effect, untracked, afterNextRender, isDevMode, forwardRef, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { NgControl, NgForm, FormGroupDirective, Validators } from '@angular/forms';
|
|
4
|
+
import { FocusMonitor, LiveAnnouncer } from '@angular/cdk/a11y';
|
|
5
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
6
|
+
import { isObservable, merge } from 'rxjs';
|
|
7
|
+
import { tv } from 'tailwind-variants';
|
|
8
|
+
import { TW_ERROR_STATE_MATCHER } from '@cdevhub/ngx-tw/core';
|
|
9
|
+
import { FormFieldControl, TW_FORM_FIELD, TW_FORM_FIELD_CONTROL } from '@cdevhub/ngx-tw/form-field';
|
|
10
|
+
import { BadgeComponent } from '@cdevhub/ngx-tw/badge';
|
|
11
|
+
|
|
12
|
+
// ── tv() config ──
|
|
13
|
+
// `focus-within:outline-{color}-500` and the chip color classes are emitted by
|
|
14
|
+
// combobox / badge already, so the Tailwind v4 JIT scanner picks them up.
|
|
15
|
+
const tagsInputVariants = tv({
|
|
16
|
+
slots: {
|
|
17
|
+
// Host is the `role="group"` chip strip. `flex-wrap` lets chips wrap; the
|
|
18
|
+
// container owns the focus-within ring (standalone) or strips its chrome
|
|
19
|
+
// inside a `<tw-form-field>` (the field draws the border / focus border).
|
|
20
|
+
root: 'flex flex-wrap items-center gap-1.5 w-full text-fg',
|
|
21
|
+
chip: 'max-w-full min-w-0',
|
|
22
|
+
chipLabel: 'truncate',
|
|
23
|
+
// Real DOM focus → canonical outline ring (NOT the activedescendant
|
|
24
|
+
// background-shift carve-out). `text-current` inherits the chip color.
|
|
25
|
+
// Per-size sets the square-interactive target size + negative margins.
|
|
26
|
+
removeButton: 'inline-flex items-center justify-center shrink-0 ml-0.5 rounded-md text-current opacity-70 hover:opacity-100 transition-opacity duration-normal motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:pointer-events-none',
|
|
27
|
+
removeIcon: 'shrink-0',
|
|
28
|
+
input: 'flex-1 min-w-24 bg-transparent text-fg placeholder:text-fg-subtle outline-none disabled:cursor-not-allowed',
|
|
29
|
+
},
|
|
30
|
+
variants: {
|
|
31
|
+
inFormField: {
|
|
32
|
+
// Inside a form-field: keep only the chip-strip layout; the field owns
|
|
33
|
+
// border, padding, background and the focused-border indicator.
|
|
34
|
+
true: { root: '' },
|
|
35
|
+
false: {
|
|
36
|
+
root: 'rounded-md border border-border bg-surface transition-colors duration-normal motion-reduce:transition-none hover:border-border-strong focus-within:outline-2 focus-within:outline-offset-2',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
size: {
|
|
40
|
+
// removeButton uses the square-interactive sub-scale (the button IS the
|
|
41
|
+
// touch target): size-6 (24px) meets WCAG 2.5.8; negative margins absorb
|
|
42
|
+
// the overflow so the chip height is unchanged. Mirrors badge's dismiss.
|
|
43
|
+
// `size-3.5` half-step on the md remove glyph lines up the X with text-sm
|
|
44
|
+
// at md density (codified half-step per CLAUDE.md icon sizing).
|
|
45
|
+
xs: { root: 'text-xs', removeButton: 'size-6 -my-1 -mr-1', removeIcon: 'size-3' },
|
|
46
|
+
sm: { root: 'text-sm', removeButton: 'size-6 -my-1 -mr-1', removeIcon: 'size-3' },
|
|
47
|
+
md: { root: 'text-sm', removeButton: 'size-7 -my-1.5 -mr-1.5', removeIcon: 'size-3.5' },
|
|
48
|
+
lg: { root: 'text-base', removeButton: 'size-8 -my-1.5 -mr-1.5', removeIcon: 'size-4' },
|
|
49
|
+
xl: { root: 'text-base', removeButton: 'size-8 -my-1.5 -mr-1.5', removeIcon: 'size-4' },
|
|
50
|
+
},
|
|
51
|
+
color: {
|
|
52
|
+
primary: { root: 'focus-within:outline-primary-500' },
|
|
53
|
+
secondary: { root: 'focus-within:outline-secondary-500' },
|
|
54
|
+
accent: { root: 'focus-within:outline-accent-500' },
|
|
55
|
+
neutral: { root: 'focus-within:outline-border-strong' },
|
|
56
|
+
info: { root: 'focus-within:outline-info-500' },
|
|
57
|
+
success: { root: 'focus-within:outline-success-500' },
|
|
58
|
+
warning: { root: 'focus-within:outline-warning-500' },
|
|
59
|
+
error: { root: 'focus-within:outline-error-500' },
|
|
60
|
+
},
|
|
61
|
+
disabled: {
|
|
62
|
+
true: { root: 'opacity-50 pointer-events-none cursor-not-allowed' },
|
|
63
|
+
false: {},
|
|
64
|
+
},
|
|
65
|
+
errorState: {
|
|
66
|
+
true: {},
|
|
67
|
+
false: {},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
compoundVariants: [
|
|
71
|
+
// Standalone density × size — inline padding scale (mirrors combobox trigger).
|
|
72
|
+
{ inFormField: false, size: 'xs', class: { root: 'px-2 py-1' } },
|
|
73
|
+
{ inFormField: false, size: 'sm', class: { root: 'px-3 py-1.5' } },
|
|
74
|
+
{ inFormField: false, size: 'md', class: { root: 'px-4 py-2' } },
|
|
75
|
+
{ inFormField: false, size: 'lg', class: { root: 'px-5 py-2.5' } },
|
|
76
|
+
{ inFormField: false, size: 'xl', class: { root: 'px-6 py-3' } },
|
|
77
|
+
// Standalone error state — colored border + focus ring.
|
|
78
|
+
{
|
|
79
|
+
inFormField: false,
|
|
80
|
+
errorState: true,
|
|
81
|
+
class: { root: 'border-error-500 focus-within:outline-error-500' },
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
defaultVariants: {
|
|
85
|
+
inFormField: false,
|
|
86
|
+
size: 'md',
|
|
87
|
+
color: 'primary',
|
|
88
|
+
disabled: false,
|
|
89
|
+
errorState: false,
|
|
90
|
+
},
|
|
91
|
+
}, { twMerge: true });
|
|
92
|
+
const DEFAULT_SEPARATORS = ['Enter', ','];
|
|
93
|
+
let nextId = 0;
|
|
94
|
+
/**
|
|
95
|
+
* Free-text multi-value input. The user types a token and commits it via Enter,
|
|
96
|
+
* a separator key, paste, or blur; committed tokens render as dismissible chips
|
|
97
|
+
* (composing `[twBadge]`) inline with the text input. The value is exposed as a
|
|
98
|
+
* real `T[]` (default `string[]`) through `ControlValueAccessor`, so it works
|
|
99
|
+
* with template-driven, reactive, and signal forms and integrates with
|
|
100
|
+
* `<tw-form-field>` for label / hint / error chrome.
|
|
101
|
+
*
|
|
102
|
+
* The control is a single tab stop with a roving-tabindex chip strip
|
|
103
|
+
* (Material `MatChipGrid` interaction model): Arrow keys traverse chips and the
|
|
104
|
+
* input, Delete / a two-step Backspace remove chips, and `LiveAnnouncer` voices
|
|
105
|
+
* add / remove. Autocomplete suggestions are out of scope (use `tw-combobox`).
|
|
106
|
+
*/
|
|
107
|
+
class TagsInputComponent extends FormFieldControl {
|
|
108
|
+
/** Semantic color for the container focus-within ring and the chip accent. Defaults to `'primary'`. */
|
|
109
|
+
color = input('primary', /* @ts-ignore */
|
|
110
|
+
...(ngDevMode ? [{ debugName: "color" }] : /* istanbul ignore next */ []));
|
|
111
|
+
/** Controls overall density: container padding, chip size, and text scale. Defaults to `'md'`. */
|
|
112
|
+
size = input('md', /* @ts-ignore */
|
|
113
|
+
...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
|
|
114
|
+
/** When true, blocks typing, committing, and chip removal, and applies muted styling. Reactive forms also propagate disabled via `setDisabledState`. Defaults to `false`. */
|
|
115
|
+
disabledInput = input(false, { ...(ngDevMode ? { debugName: "disabledInput" } : /* istanbul ignore next */ {}), alias: 'disabled' });
|
|
116
|
+
/** Marks the control as required. Mirrored to the input's `aria-required`. Also inferred from `Validators.required` on a bound control. Defaults to `false`. */
|
|
117
|
+
requiredInput = input(false, { ...(ngDevMode ? { debugName: "requiredInput" } : /* istanbul ignore next */ {}), alias: 'required' });
|
|
118
|
+
/** Placeholder shown in the text input only while there are no chips and the input is empty. */
|
|
119
|
+
placeholder = input(undefined, /* @ts-ignore */
|
|
120
|
+
...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
|
|
121
|
+
/** Keys that commit the in-progress text as a tag. Each entry is a `KeyboardEvent.key` value (`'Enter'`) or a single separator character (`','`). Single-character separators also split pasted text. Defaults to `['Enter', ',']`. */
|
|
122
|
+
separatorKeys = input(DEFAULT_SEPARATORS, /* @ts-ignore */
|
|
123
|
+
...(ngDevMode ? [{ debugName: "separatorKeys" }] : /* istanbul ignore next */ []));
|
|
124
|
+
/** When true, blurring the control while the input holds non-empty text commits it as a tag. Defaults to `false`. */
|
|
125
|
+
addOnBlur = input(false, /* @ts-ignore */
|
|
126
|
+
...(ngDevMode ? [{ debugName: "addOnBlur" }] : /* istanbul ignore next */ []));
|
|
127
|
+
/** Maximum number of tags. Once reached, further commits are blocked and announced. Does not truncate an oversized `writeValue`. Defaults to `undefined` (no limit). */
|
|
128
|
+
maxTags = input(undefined, /* @ts-ignore */
|
|
129
|
+
...(ngDevMode ? [{ debugName: "maxTags" }] : /* istanbul ignore next */ []));
|
|
130
|
+
/** When false (default), a committed tag equal (per `compareWith`) to an existing tag is dropped silently. When true, duplicates are kept. Defaults to `false`. */
|
|
131
|
+
allowDuplicates = input(false, /* @ts-ignore */
|
|
132
|
+
...(ngDevMode ? [{ debugName: "allowDuplicates" }] : /* istanbul ignore next */ []));
|
|
133
|
+
/** Maps committed text to a tag value. Default is identity — the trimmed string. Override to build object tags. */
|
|
134
|
+
createTag = input(((text) => text), /* @ts-ignore */
|
|
135
|
+
...(ngDevMode ? [{ debugName: "createTag" }] : /* istanbul ignore next */ []));
|
|
136
|
+
/** Maps a tag value to its visible chip label and the remove-button accessible name. Defaults to `String(tag)`. */
|
|
137
|
+
tagLabel = input((tag) => String(tag), /* @ts-ignore */
|
|
138
|
+
...(ngDevMode ? [{ debugName: "tagLabel" }] : /* istanbul ignore next */ []));
|
|
139
|
+
/** Equality comparator used for dedup when `allowDuplicates` is false. Defaults to `Object.is` (reference / value identity). String tags dedupe case-sensitively by default; pass `(a, b) => a.toLowerCase() === b.toLowerCase()` for case-insensitive dedup. */
|
|
140
|
+
compareWith = input((a, b) => Object.is(a, b), /* @ts-ignore */
|
|
141
|
+
...(ngDevMode ? [{ debugName: "compareWith" }] : /* istanbul ignore next */ []));
|
|
142
|
+
/** Applied to the text input for labeling and identification only; does not submit the tag array via native (non-Angular) form posting. */
|
|
143
|
+
name = input(undefined, /* @ts-ignore */
|
|
144
|
+
...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
|
|
145
|
+
/** Id on the host element. Auto-generated as `tw-tags-input-N` when not provided. Used by the form-field's `<label for>` association. */
|
|
146
|
+
idInput = input(undefined, { ...(ngDevMode ? { debugName: "idInput" } : /* istanbul ignore next */ {}), alias: 'id' });
|
|
147
|
+
/** Accessible name applied to the control when no visible label is wired. Mirrored to `aria-label`. */
|
|
148
|
+
ariaLabel = input(undefined, { ...(ngDevMode ? { debugName: "ariaLabel" } : /* istanbul ignore next */ {}), alias: 'aria-label' });
|
|
149
|
+
/** ID of an external element that labels the control. Mirrored to `aria-labelledby`. */
|
|
150
|
+
ariaLabelledby = input(undefined, { ...(ngDevMode ? { debugName: "ariaLabelledby" } : /* istanbul ignore next */ {}), alias: 'aria-labelledby' });
|
|
151
|
+
/** ID of an external element that describes the control. Form-field merges its hint / error ids alongside. */
|
|
152
|
+
ariaDescribedby = input(undefined, { ...(ngDevMode ? { debugName: "ariaDescribedby" } : /* istanbul ignore next */ {}), alias: 'aria-describedby' });
|
|
153
|
+
/** Per-instance override of the {@link ErrorStateMatcher}. When omitted, uses the `TW_ERROR_STATE_MATCHER` token's value. */
|
|
154
|
+
errorStateMatcher = input(undefined, /* @ts-ignore */
|
|
155
|
+
...(ngDevMode ? [{ debugName: "errorStateMatcher" }] : /* istanbul ignore next */ []));
|
|
156
|
+
/** Fires when the tag array changes through user interaction (add, remove, clear). Emits a fresh array reference. Does not fire on `writeValue`. */
|
|
157
|
+
valueChange = output();
|
|
158
|
+
/** Fires when a tag is committed via Enter, a separator key, paste, or `addTag()`. Does not fire for dropped duplicates, blocked-by-max commits, empty commits, or `writeValue`. */
|
|
159
|
+
tagAdded = output();
|
|
160
|
+
/** Fires when a tag is removed via the remove button, Backspace / Delete, or `removeTag()`. Does not fire on `writeValue` or `clear()`. */
|
|
161
|
+
tagRemoved = output();
|
|
162
|
+
focusMonitor = inject(FocusMonitor);
|
|
163
|
+
liveAnnouncer = inject(LiveAnnouncer);
|
|
164
|
+
elementRef = inject((ElementRef));
|
|
165
|
+
destroyRef = inject(DestroyRef);
|
|
166
|
+
ngControl = inject(NgControl, { optional: true, self: true });
|
|
167
|
+
parentForm = inject(NgForm, { optional: true });
|
|
168
|
+
parentFormGroup = inject(FormGroupDirective, { optional: true });
|
|
169
|
+
defaultMatcher = inject(TW_ERROR_STATE_MATCHER);
|
|
170
|
+
formField = inject(TW_FORM_FIELD, { optional: true });
|
|
171
|
+
textInputRef = viewChild('textInput', /* @ts-ignore */
|
|
172
|
+
...(ngDevMode ? [{ debugName: "textInputRef" }] : /* istanbul ignore next */ []));
|
|
173
|
+
removeButtons = viewChildren('removeBtn', /* @ts-ignore */
|
|
174
|
+
...(ngDevMode ? [{ debugName: "removeButtons" }] : /* istanbul ignore next */ []));
|
|
175
|
+
onChange = () => { };
|
|
176
|
+
onTouched = () => { };
|
|
177
|
+
tags = signal([], /* @ts-ignore */
|
|
178
|
+
...(ngDevMode ? [{ debugName: "tags" }] : /* istanbul ignore next */ []));
|
|
179
|
+
/** The current in-progress (uncommitted) text in the input. Empty when nothing is typed. */
|
|
180
|
+
inputText = signal('', /* @ts-ignore */
|
|
181
|
+
...(ngDevMode ? [{ debugName: "inputText" }] : /* istanbul ignore next */ []));
|
|
182
|
+
/** @internal Roving-tabindex position. `null` = the text input is the active element; a number = that chip's remove button. */
|
|
183
|
+
activeChipIndex = signal(null, /* @ts-ignore */
|
|
184
|
+
...(ngDevMode ? [{ debugName: "activeChipIndex" }] : /* istanbul ignore next */ []));
|
|
185
|
+
cvaDisabled = signal(false, /* @ts-ignore */
|
|
186
|
+
...(ngDevMode ? [{ debugName: "cvaDisabled" }] : /* istanbul ignore next */ []));
|
|
187
|
+
_focused = signal(false, /* @ts-ignore */
|
|
188
|
+
...(ngDevMode ? [{ debugName: "_focused" }] : /* istanbul ignore next */ []));
|
|
189
|
+
_ngControlRev = signal(0, /* @ts-ignore */
|
|
190
|
+
...(ngDevMode ? [{ debugName: "_ngControlRev" }] : /* istanbul ignore next */ []));
|
|
191
|
+
_formSubmitRev = signal(0, /* @ts-ignore */
|
|
192
|
+
...(ngDevMode ? [{ debugName: "_formSubmitRev" }] : /* istanbul ignore next */ []));
|
|
193
|
+
describedByIdsSignal = signal([], /* @ts-ignore */
|
|
194
|
+
...(ngDevMode ? [{ debugName: "describedByIdsSignal" }] : /* istanbul ignore next */ []));
|
|
195
|
+
labelledByIdsSignal = signal([], /* @ts-ignore */
|
|
196
|
+
...(ngDevMode ? [{ debugName: "labelledByIdsSignal" }] : /* istanbul ignore next */ []));
|
|
197
|
+
/** Nonce bumped only on explicit focus intent — keeps the focus effect from stealing focus on unrelated renders. */
|
|
198
|
+
focusNonce = signal(0, /* @ts-ignore */
|
|
199
|
+
...(ngDevMode ? [{ debugName: "focusNonce" }] : /* istanbul ignore next */ []));
|
|
200
|
+
lastMaxAnnounce = 0;
|
|
201
|
+
uid = nextId++;
|
|
202
|
+
/** @internal Fallback id used when the consumer does not set `[id]`. */
|
|
203
|
+
hostId = `tw-tags-input-${this.uid}`;
|
|
204
|
+
// ── FormFieldControl signals ──
|
|
205
|
+
/** @internal */
|
|
206
|
+
id = computed(() => this.idInput() ?? this.hostId, /* @ts-ignore */
|
|
207
|
+
...(ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []));
|
|
208
|
+
/** The current tag array (empty array when there are no tags). Read from a template ref (`#t="twTagsInput"`) for non-form usage. Doubles as the `FormFieldControl` contract member. */
|
|
209
|
+
value = this.tags.asReadonly();
|
|
210
|
+
/** @internal */
|
|
211
|
+
focused = this._focused.asReadonly();
|
|
212
|
+
/** @internal Empty when there are no chips AND the input text is blank — drives the form-field floating label. */
|
|
213
|
+
empty = computed(() => this.tags().length === 0 && this.inputText().trim().length === 0, /* @ts-ignore */
|
|
214
|
+
...(ngDevMode ? [{ debugName: "empty" }] : /* istanbul ignore next */ []));
|
|
215
|
+
/** @internal */
|
|
216
|
+
disabled = computed(() => this.disabledInput() || this.cvaDisabled(), /* @ts-ignore */
|
|
217
|
+
...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
|
|
218
|
+
/** @internal */
|
|
219
|
+
required = computed(() => {
|
|
220
|
+
this._ngControlRev();
|
|
221
|
+
if (this.requiredInput())
|
|
222
|
+
return true;
|
|
223
|
+
return !!this.ngControl?.control?.hasValidator(Validators.required);
|
|
224
|
+
}, /* @ts-ignore */
|
|
225
|
+
...(ngDevMode ? [{ debugName: "required" }] : /* istanbul ignore next */ []));
|
|
226
|
+
/** @internal */
|
|
227
|
+
errorState = computed(() => {
|
|
228
|
+
this._ngControlRev();
|
|
229
|
+
this._formSubmitRev();
|
|
230
|
+
this._focused();
|
|
231
|
+
const matcher = this.errorStateMatcher() ?? this.defaultMatcher;
|
|
232
|
+
const form = this.parentFormGroup ??
|
|
233
|
+
this.parentForm;
|
|
234
|
+
return matcher.isErrorState(this.ngControl?.control ?? null, form);
|
|
235
|
+
}, /* @ts-ignore */
|
|
236
|
+
...(ngDevMode ? [{ debugName: "errorState" }] : /* istanbul ignore next */ []));
|
|
237
|
+
/** @internal Active validation errors map from the bound control, for `[twError match="…"]` filtering. */
|
|
238
|
+
errors = computed(() => {
|
|
239
|
+
this._ngControlRev();
|
|
240
|
+
return this.ngControl?.control?.errors ?? null;
|
|
241
|
+
}, /* @ts-ignore */
|
|
242
|
+
...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
|
|
243
|
+
/** @internal */
|
|
244
|
+
controlType = 'tags-input';
|
|
245
|
+
/** @internal */
|
|
246
|
+
userAriaDescribedBy = computed(() => this.ariaDescribedby(), /* @ts-ignore */
|
|
247
|
+
...(ngDevMode ? [{ debugName: "userAriaDescribedBy" }] : /* istanbul ignore next */ []));
|
|
248
|
+
/** @internal */
|
|
249
|
+
userAriaLabelledby = computed(() => this.ariaLabelledby(), /* @ts-ignore */
|
|
250
|
+
...(ngDevMode ? [{ debugName: "userAriaLabelledby" }] : /* istanbul ignore next */ []));
|
|
251
|
+
// ── Effective ARIA ──
|
|
252
|
+
//
|
|
253
|
+
// When wrapped by `<tw-form-field>`, the form-field pushes the FULLY MERGED
|
|
254
|
+
// id list (hint/error/label + the consumer's `aria-*by` ids) via
|
|
255
|
+
// `setDescribedByIds` / `setLabelledByIds`. In that case the pushed ids alone
|
|
256
|
+
// are the source of truth — re-merging the consumer's `aria-*by` input on top
|
|
257
|
+
// would duplicate ids. When standalone, fall back to the consumer-supplied
|
|
258
|
+
// `aria-*by` input.
|
|
259
|
+
/** @internal Merged `aria-describedby` (form-field hint/error ids + consumer-supplied). */
|
|
260
|
+
resolvedDescribedBy = computed(() => {
|
|
261
|
+
const pushed = this.describedByIdsSignal();
|
|
262
|
+
if (pushed.length)
|
|
263
|
+
return pushed.join(' ');
|
|
264
|
+
const user = this.ariaDescribedby();
|
|
265
|
+
if (user)
|
|
266
|
+
return user.split(/\s+/).filter(Boolean).join(' ');
|
|
267
|
+
return '';
|
|
268
|
+
}, /* @ts-ignore */
|
|
269
|
+
...(ngDevMode ? [{ debugName: "resolvedDescribedBy" }] : /* istanbul ignore next */ []));
|
|
270
|
+
/** @internal Merged `aria-labelledby` (form-field label id + consumer-supplied). */
|
|
271
|
+
resolvedLabelledBy = computed(() => {
|
|
272
|
+
const pushed = this.labelledByIdsSignal();
|
|
273
|
+
if (pushed.length)
|
|
274
|
+
return pushed.join(' ');
|
|
275
|
+
const user = this.ariaLabelledby();
|
|
276
|
+
if (user)
|
|
277
|
+
return user.split(/\s+/).filter(Boolean).join(' ');
|
|
278
|
+
return '';
|
|
279
|
+
}, /* @ts-ignore */
|
|
280
|
+
...(ngDevMode ? [{ debugName: "resolvedLabelledBy" }] : /* istanbul ignore next */ []));
|
|
281
|
+
// ── tv() class bindings ──
|
|
282
|
+
variantResult = computed(() => tagsInputVariants({
|
|
283
|
+
inFormField: !!this.formField,
|
|
284
|
+
size: this.size(),
|
|
285
|
+
color: this.color(),
|
|
286
|
+
disabled: this.disabled(),
|
|
287
|
+
errorState: this.errorState(),
|
|
288
|
+
}), /* @ts-ignore */
|
|
289
|
+
...(ngDevMode ? [{ debugName: "variantResult" }] : /* istanbul ignore next */ []));
|
|
290
|
+
/** @internal */
|
|
291
|
+
rootClasses = computed(() => this.variantResult().root(), /* @ts-ignore */
|
|
292
|
+
...(ngDevMode ? [{ debugName: "rootClasses" }] : /* istanbul ignore next */ []));
|
|
293
|
+
/** @internal */
|
|
294
|
+
chipClasses = computed(() => this.variantResult().chip(), /* @ts-ignore */
|
|
295
|
+
...(ngDevMode ? [{ debugName: "chipClasses" }] : /* istanbul ignore next */ []));
|
|
296
|
+
/** @internal */
|
|
297
|
+
chipLabelClasses = computed(() => this.variantResult().chipLabel(), /* @ts-ignore */
|
|
298
|
+
...(ngDevMode ? [{ debugName: "chipLabelClasses" }] : /* istanbul ignore next */ []));
|
|
299
|
+
/** @internal */
|
|
300
|
+
removeButtonClasses = computed(() => this.variantResult().removeButton(), /* @ts-ignore */
|
|
301
|
+
...(ngDevMode ? [{ debugName: "removeButtonClasses" }] : /* istanbul ignore next */ []));
|
|
302
|
+
/** @internal */
|
|
303
|
+
removeIconClasses = computed(() => this.variantResult().removeIcon(), /* @ts-ignore */
|
|
304
|
+
...(ngDevMode ? [{ debugName: "removeIconClasses" }] : /* istanbul ignore next */ []));
|
|
305
|
+
/** @internal */
|
|
306
|
+
inputClasses = computed(() => this.variantResult().input(), /* @ts-ignore */
|
|
307
|
+
...(ngDevMode ? [{ debugName: "inputClasses" }] : /* istanbul ignore next */ []));
|
|
308
|
+
/** @internal Pre-resolved chip view models so the template never calls accessor functions inline. */
|
|
309
|
+
chipViews = computed(() => {
|
|
310
|
+
const label = this.tagLabel();
|
|
311
|
+
return this.tags().map((tag, index) => {
|
|
312
|
+
const text = label(tag);
|
|
313
|
+
return { tag, label: text, removeLabel: `Remove ${text}`, index };
|
|
314
|
+
});
|
|
315
|
+
}, /* @ts-ignore */
|
|
316
|
+
...(ngDevMode ? [{ debugName: "chipViews" }] : /* istanbul ignore next */ []));
|
|
317
|
+
constructor() {
|
|
318
|
+
super();
|
|
319
|
+
// Runtime CVA wiring — register on any host-level NgControl. The component
|
|
320
|
+
// injects `NgControl { self }` for TW_ERROR_STATE_MATCHER, so a static
|
|
321
|
+
// NG_VALUE_ACCESSOR provider would create circular DI on the same instance.
|
|
322
|
+
if (this.ngControl) {
|
|
323
|
+
this.ngControl.valueAccessor = this;
|
|
324
|
+
}
|
|
325
|
+
// Focus side-effect (leaves the signal graph; writes no signal → no cycle).
|
|
326
|
+
// Triggered only by `focusNonce` bumps so it never steals focus on an
|
|
327
|
+
// unrelated render (e.g. initial mount). Reads the active index and refs
|
|
328
|
+
// untracked so it does not re-fire when the chip list changes on its own.
|
|
329
|
+
effect(() => {
|
|
330
|
+
if (this.focusNonce() === 0)
|
|
331
|
+
return;
|
|
332
|
+
untracked(() => {
|
|
333
|
+
const idx = this.activeChipIndex();
|
|
334
|
+
if (idx === null) {
|
|
335
|
+
this.textInputRef()?.nativeElement.focus();
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
this.removeButtons()[idx]?.nativeElement.focus();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
afterNextRender(() => {
|
|
343
|
+
if (isDevMode() && !this.hasAccessibleNameHint()) {
|
|
344
|
+
console.warn('[tw-tags-input] The control has no accessible name. Provide `aria-label` / `aria-labelledby`, or wrap it in a <tw-form-field> with a <label twLabel>.');
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
// ── Public instance API ──
|
|
349
|
+
/** Commits `text` as a tag (trim → `createTag` → dedup unless `allowDuplicates` → `maxTags` check). Returns true if a tag was added, false if dropped (empty / duplicate / max). Emits `tagAdded` + `valueChange` on success. No-op when disabled. */
|
|
350
|
+
addTag(text) {
|
|
351
|
+
if (this.disabled())
|
|
352
|
+
return false;
|
|
353
|
+
const trimmed = text.trim();
|
|
354
|
+
if (!trimmed)
|
|
355
|
+
return false;
|
|
356
|
+
return this.tryAdd(this.createTag()(trimmed)) === 'added';
|
|
357
|
+
}
|
|
358
|
+
/** Removes a tag by value (first match via `compareWith`) or by index when a number is passed. Emits `tagRemoved` + `valueChange` and restores focus. No-op when disabled or when no match. Note: when the tag type is `number`, the argument is treated as an index. */
|
|
359
|
+
removeTag(tag) {
|
|
360
|
+
if (this.disabled())
|
|
361
|
+
return;
|
|
362
|
+
if (typeof tag === 'number') {
|
|
363
|
+
this.removeAt(tag);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const cmp = this.compareWith();
|
|
367
|
+
const idx = this.tags().findIndex((t) => cmp(t, tag));
|
|
368
|
+
if (idx >= 0)
|
|
369
|
+
this.removeAt(idx);
|
|
370
|
+
}
|
|
371
|
+
/** Removes all tags and clears the in-progress input text. Emits `valueChange` with `[]`. Does NOT emit `tagRemoved` per tag (bulk reset). No-op when already empty or disabled. */
|
|
372
|
+
clear() {
|
|
373
|
+
if (this.disabled())
|
|
374
|
+
return;
|
|
375
|
+
if (this.tags().length === 0 && this.inputText().length === 0)
|
|
376
|
+
return;
|
|
377
|
+
this.tags.set([]);
|
|
378
|
+
this.setInputText('');
|
|
379
|
+
this.activeChipIndex.set(null);
|
|
380
|
+
this.onChange([]);
|
|
381
|
+
this.valueChange.emit([]);
|
|
382
|
+
this.liveAnnouncer.announce('All tags removed', 'polite');
|
|
383
|
+
}
|
|
384
|
+
/** Moves focus to the text input. */
|
|
385
|
+
focus() {
|
|
386
|
+
this.activeChipIndex.set(null);
|
|
387
|
+
this.requestFocus();
|
|
388
|
+
}
|
|
389
|
+
// ── Internal commit / remove pipeline ──
|
|
390
|
+
tryAdd(tag) {
|
|
391
|
+
const current = this.tags();
|
|
392
|
+
if (!this.allowDuplicates()) {
|
|
393
|
+
const cmp = this.compareWith();
|
|
394
|
+
if (current.some((e) => cmp(e, tag)))
|
|
395
|
+
return 'duplicate';
|
|
396
|
+
}
|
|
397
|
+
const max = this.maxTags();
|
|
398
|
+
if (max !== undefined && current.length >= max) {
|
|
399
|
+
this.announceMax();
|
|
400
|
+
return 'max';
|
|
401
|
+
}
|
|
402
|
+
const next = [...current, tag];
|
|
403
|
+
this.tags.set(next);
|
|
404
|
+
this.onChange(next);
|
|
405
|
+
this.tagAdded.emit({ tag, value: next });
|
|
406
|
+
this.valueChange.emit(next);
|
|
407
|
+
this.liveAnnouncer.announce(`${this.tagLabel()(tag)} added`, 'polite');
|
|
408
|
+
return 'added';
|
|
409
|
+
}
|
|
410
|
+
commitInput() {
|
|
411
|
+
if (this.disabled())
|
|
412
|
+
return;
|
|
413
|
+
const trimmed = this.inputText().trim();
|
|
414
|
+
if (!trimmed) {
|
|
415
|
+
this.setInputText('');
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const result = this.tryAdd(this.createTag()(trimmed));
|
|
419
|
+
// Clear on success or duplicate (gesture consumed); retain on max so the
|
|
420
|
+
// user can retry after removing a tag.
|
|
421
|
+
if (result !== 'max')
|
|
422
|
+
this.setInputText('');
|
|
423
|
+
}
|
|
424
|
+
removeAt(index) {
|
|
425
|
+
if (this.disabled())
|
|
426
|
+
return;
|
|
427
|
+
const current = this.tags();
|
|
428
|
+
if (index < 0 || index >= current.length)
|
|
429
|
+
return;
|
|
430
|
+
const tag = current[index];
|
|
431
|
+
const next = current.filter((_, i) => i !== index);
|
|
432
|
+
this.tags.set(next);
|
|
433
|
+
this.onChange(next);
|
|
434
|
+
this.tagRemoved.emit({ tag, value: next, index });
|
|
435
|
+
this.valueChange.emit(next);
|
|
436
|
+
this.liveAnnouncer.announce(`${this.tagLabel()(tag)} removed`, 'polite');
|
|
437
|
+
// Focus restoration: next chip at this index, else the previous chip, else the input.
|
|
438
|
+
if (next.length === 0) {
|
|
439
|
+
this.focusInput();
|
|
440
|
+
}
|
|
441
|
+
else if (index < next.length) {
|
|
442
|
+
this.focusChip(index);
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
this.focusChip(next.length - 1);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// ── DOM event handlers ──
|
|
449
|
+
/** @internal */
|
|
450
|
+
onInput(event) {
|
|
451
|
+
this.inputText.set(event.target.value);
|
|
452
|
+
}
|
|
453
|
+
/** @internal */
|
|
454
|
+
onInputKeydown(event) {
|
|
455
|
+
if (this.disabled())
|
|
456
|
+
return;
|
|
457
|
+
const separators = this.separatorKeys();
|
|
458
|
+
const key = event.key;
|
|
459
|
+
// Single-character separator → commit (and swallow the character).
|
|
460
|
+
if (key.length === 1 && separators.includes(key)) {
|
|
461
|
+
event.preventDefault();
|
|
462
|
+
this.commitInput();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// Enter separator → commit only when there is text (otherwise let the form submit).
|
|
466
|
+
if (key === 'Enter' && separators.includes('Enter')) {
|
|
467
|
+
if (this.inputText().trim().length > 0) {
|
|
468
|
+
event.preventDefault();
|
|
469
|
+
this.commitInput();
|
|
470
|
+
}
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (key === 'ArrowLeft') {
|
|
474
|
+
const el = event.target;
|
|
475
|
+
const atStart = (el.selectionStart ?? 0) === 0 && (el.selectionEnd ?? 0) === 0;
|
|
476
|
+
if (atStart && this.tags().length > 0) {
|
|
477
|
+
event.preventDefault();
|
|
478
|
+
this.focusChip(this.tags().length - 1);
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (key === 'Backspace' && this.inputText().length === 0) {
|
|
483
|
+
if (this.tags().length > 0) {
|
|
484
|
+
event.preventDefault();
|
|
485
|
+
this.focusChip(this.tags().length - 1);
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (key === 'Escape' && this.inputText().length > 0) {
|
|
490
|
+
event.preventDefault();
|
|
491
|
+
this.setInputText('');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/** @internal */
|
|
495
|
+
onChipKeydown(event, index) {
|
|
496
|
+
if (this.disabled())
|
|
497
|
+
return;
|
|
498
|
+
const last = this.tags().length - 1;
|
|
499
|
+
switch (event.key) {
|
|
500
|
+
case 'ArrowLeft':
|
|
501
|
+
event.preventDefault();
|
|
502
|
+
if (index > 0)
|
|
503
|
+
this.focusChip(index - 1);
|
|
504
|
+
return;
|
|
505
|
+
case 'ArrowRight':
|
|
506
|
+
event.preventDefault();
|
|
507
|
+
if (index < last)
|
|
508
|
+
this.focusChip(index + 1);
|
|
509
|
+
else
|
|
510
|
+
this.focusInput();
|
|
511
|
+
return;
|
|
512
|
+
case 'Home':
|
|
513
|
+
event.preventDefault();
|
|
514
|
+
this.focusChip(0);
|
|
515
|
+
return;
|
|
516
|
+
case 'End':
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
this.focusInput();
|
|
519
|
+
return;
|
|
520
|
+
case 'Delete':
|
|
521
|
+
case 'Backspace':
|
|
522
|
+
event.preventDefault();
|
|
523
|
+
this.removeAt(index);
|
|
524
|
+
return;
|
|
525
|
+
case 'Escape':
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
this.focusInput();
|
|
528
|
+
return;
|
|
529
|
+
default:
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/** @internal */
|
|
534
|
+
onRemoveClick(index) {
|
|
535
|
+
this.removeAt(index);
|
|
536
|
+
}
|
|
537
|
+
/** @internal */
|
|
538
|
+
onPaste(event) {
|
|
539
|
+
if (this.disabled())
|
|
540
|
+
return;
|
|
541
|
+
const data = event.clipboardData?.getData('text') ?? '';
|
|
542
|
+
const charSeparators = this.separatorKeys().filter((k) => k.length === 1);
|
|
543
|
+
const hasSeparator = charSeparators.some((s) => data.includes(s));
|
|
544
|
+
if (!hasSeparator) {
|
|
545
|
+
// No separator: let the browser merge the text into the input natively.
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
event.preventDefault();
|
|
549
|
+
const combined = this.inputText() + data;
|
|
550
|
+
this.setInputText('');
|
|
551
|
+
for (const piece of this.splitOnSeparators(combined, charSeparators)) {
|
|
552
|
+
const trimmed = piece.trim();
|
|
553
|
+
if (!trimmed)
|
|
554
|
+
continue;
|
|
555
|
+
if (this.tryAdd(this.createTag()(trimmed)) === 'max')
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// ── ControlValueAccessor ──
|
|
560
|
+
writeValue(value) {
|
|
561
|
+
this.tags.set(Array.isArray(value) ? [...value] : []);
|
|
562
|
+
this.setInputText('');
|
|
563
|
+
}
|
|
564
|
+
registerOnChange(fn) {
|
|
565
|
+
this.onChange = fn;
|
|
566
|
+
}
|
|
567
|
+
registerOnTouched(fn) {
|
|
568
|
+
this.onTouched = fn;
|
|
569
|
+
}
|
|
570
|
+
setDisabledState(isDisabled) {
|
|
571
|
+
this.cvaDisabled.set(isDisabled);
|
|
572
|
+
}
|
|
573
|
+
// ── FormFieldControl methods ──
|
|
574
|
+
/** @internal */
|
|
575
|
+
setDescribedByIds(ids) {
|
|
576
|
+
this.describedByIdsSignal.set([...ids]);
|
|
577
|
+
}
|
|
578
|
+
/** @internal */
|
|
579
|
+
setLabelledByIds(ids) {
|
|
580
|
+
this.labelledByIdsSignal.set([...ids]);
|
|
581
|
+
}
|
|
582
|
+
/** @internal Clicking the form-field surface (outside the control) focuses the text input. Clicks inside the control are left alone so chip removal keeps its own focus restoration. */
|
|
583
|
+
onContainerClick(event) {
|
|
584
|
+
if (this.disabled())
|
|
585
|
+
return;
|
|
586
|
+
if (event.defaultPrevented)
|
|
587
|
+
return;
|
|
588
|
+
const host = this.elementRef.nativeElement;
|
|
589
|
+
if (event.target === host || host.contains(event.target))
|
|
590
|
+
return;
|
|
591
|
+
this.focus();
|
|
592
|
+
}
|
|
593
|
+
// ── Lifecycle ──
|
|
594
|
+
ngOnInit() {
|
|
595
|
+
// checkChildren: true — internal focus moves (input ↔ chips) stay "focused";
|
|
596
|
+
// origin === null fires only when focus leaves the whole control, the single
|
|
597
|
+
// source for onTouched and the addOnBlur commit.
|
|
598
|
+
const monitor$ = this.focusMonitor.monitor(this.elementRef, true);
|
|
599
|
+
if (isObservable(monitor$)) {
|
|
600
|
+
monitor$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((origin) => {
|
|
601
|
+
const wasFocused = this._focused();
|
|
602
|
+
this._focused.set(!!origin);
|
|
603
|
+
if (wasFocused && !origin) {
|
|
604
|
+
if (this.addOnBlur() && this.inputText().trim().length > 0) {
|
|
605
|
+
this.commitInput();
|
|
606
|
+
}
|
|
607
|
+
this.onTouched();
|
|
608
|
+
this._ngControlRev.update((n) => n + 1);
|
|
609
|
+
// Reset roving so the next Tab into the control lands on the input.
|
|
610
|
+
this.activeChipIndex.set(null);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
this.destroyRef.onDestroy(() => this.focusMonitor.stopMonitoring(this.elementRef));
|
|
615
|
+
const ctrl = this.ngControl?.control;
|
|
616
|
+
if (ctrl) {
|
|
617
|
+
const streams = [ctrl.statusChanges, ctrl.valueChanges].filter((s) => !!s);
|
|
618
|
+
if (streams.length) {
|
|
619
|
+
merge(...streams)
|
|
620
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
621
|
+
.subscribe(() => this._ngControlRev.update((n) => n + 1));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const submit = this.parentFormGroup?.ngSubmit ?? this.parentForm?.ngSubmit;
|
|
625
|
+
if (submit) {
|
|
626
|
+
submit
|
|
627
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
628
|
+
.subscribe(() => this._formSubmitRev.update((n) => n + 1));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// ── Helpers ──
|
|
632
|
+
focusChip(index) {
|
|
633
|
+
this.activeChipIndex.set(index);
|
|
634
|
+
this.requestFocus();
|
|
635
|
+
}
|
|
636
|
+
focusInput() {
|
|
637
|
+
this.activeChipIndex.set(null);
|
|
638
|
+
this.requestFocus();
|
|
639
|
+
}
|
|
640
|
+
requestFocus() {
|
|
641
|
+
this.focusNonce.update((n) => n + 1);
|
|
642
|
+
}
|
|
643
|
+
setInputText(value) {
|
|
644
|
+
this.inputText.set(value);
|
|
645
|
+
const el = this.textInputRef()?.nativeElement;
|
|
646
|
+
if (el && el.value !== value)
|
|
647
|
+
el.value = value;
|
|
648
|
+
}
|
|
649
|
+
announceMax() {
|
|
650
|
+
const now = Date.now();
|
|
651
|
+
if (now - this.lastMaxAnnounce < 500)
|
|
652
|
+
return;
|
|
653
|
+
this.lastMaxAnnounce = now;
|
|
654
|
+
this.liveAnnouncer.announce(`Maximum ${this.maxTags()} tags reached`, 'assertive');
|
|
655
|
+
}
|
|
656
|
+
splitOnSeparators(text, separators) {
|
|
657
|
+
if (separators.length === 0)
|
|
658
|
+
return [text];
|
|
659
|
+
let normalized = text;
|
|
660
|
+
for (const s of separators)
|
|
661
|
+
normalized = normalized.split(s).join('\u0000');
|
|
662
|
+
return normalized.split('\u0000');
|
|
663
|
+
}
|
|
664
|
+
hasAccessibleNameHint() {
|
|
665
|
+
return (!!this.ariaLabel() ||
|
|
666
|
+
!!this.ariaLabelledby() ||
|
|
667
|
+
this.labelledByIdsSignal().length > 0);
|
|
668
|
+
}
|
|
669
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: TagsInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
670
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: TagsInputComponent, isStandalone: true, selector: "tw-tags-input", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, disabledInput: { classPropertyName: "disabledInput", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, requiredInput: { classPropertyName: "requiredInput", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, separatorKeys: { classPropertyName: "separatorKeys", publicName: "separatorKeys", isSignal: true, isRequired: false, transformFunction: null }, addOnBlur: { classPropertyName: "addOnBlur", publicName: "addOnBlur", isSignal: true, isRequired: false, transformFunction: null }, maxTags: { classPropertyName: "maxTags", publicName: "maxTags", isSignal: true, isRequired: false, transformFunction: null }, allowDuplicates: { classPropertyName: "allowDuplicates", publicName: "allowDuplicates", isSignal: true, isRequired: false, transformFunction: null }, createTag: { classPropertyName: "createTag", publicName: "createTag", isSignal: true, isRequired: false, transformFunction: null }, tagLabel: { classPropertyName: "tagLabel", publicName: "tagLabel", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, idInput: { classPropertyName: "idInput", publicName: "id", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "aria-label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "aria-labelledby", isSignal: true, isRequired: false, transformFunction: null }, ariaDescribedby: { classPropertyName: "ariaDescribedby", publicName: "aria-describedby", isSignal: true, isRequired: false, transformFunction: null }, errorStateMatcher: { classPropertyName: "errorStateMatcher", publicName: "errorStateMatcher", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueChange: "valueChange", tagAdded: "tagAdded", tagRemoved: "tagRemoved" }, host: { attributes: { "role": "group" }, properties: { "id": "id()", "class": "rootClasses()", "attr.aria-label": "ariaLabel() || null", "attr.aria-labelledby": "resolvedLabelledBy() || null", "attr.aria-describedby": "resolvedDescribedBy() || null", "attr.aria-disabled": "disabled() || null", "attr.aria-invalid": "errorState() || null", "attr.data-focused": "focused() || null" } }, providers: [
|
|
671
|
+
{
|
|
672
|
+
provide: TW_FORM_FIELD_CONTROL,
|
|
673
|
+
useExisting: forwardRef(() => TagsInputComponent),
|
|
674
|
+
},
|
|
675
|
+
], viewQueries: [{ propertyName: "textInputRef", first: true, predicate: ["textInput"], descendants: true, isSignal: true }, { propertyName: "removeButtons", predicate: ["removeBtn"], descendants: true, isSignal: true }], exportAs: ["twTagsInput"], usesInheritance: true, ngImport: i0, template: `
|
|
676
|
+
@for (chip of chipViews(); track chip.index) {
|
|
677
|
+
<span twBadge variant="soft" [color]="color()" [size]="size()" [class]="chipClasses()">
|
|
678
|
+
<span [class]="chipLabelClasses()">{{ chip.label }}</span>
|
|
679
|
+
<button
|
|
680
|
+
#removeBtn
|
|
681
|
+
type="button"
|
|
682
|
+
[class]="removeButtonClasses()"
|
|
683
|
+
[attr.tabindex]="activeChipIndex() === chip.index ? 0 : -1"
|
|
684
|
+
[attr.aria-label]="chip.removeLabel"
|
|
685
|
+
[disabled]="disabled()"
|
|
686
|
+
(click)="onRemoveClick(chip.index)"
|
|
687
|
+
(keydown)="onChipKeydown($event, chip.index)"
|
|
688
|
+
>
|
|
689
|
+
<svg
|
|
690
|
+
viewBox="0 0 16 16"
|
|
691
|
+
fill="currentColor"
|
|
692
|
+
aria-hidden="true"
|
|
693
|
+
[class]="removeIconClasses()"
|
|
694
|
+
>
|
|
695
|
+
<path
|
|
696
|
+
d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z"
|
|
697
|
+
/>
|
|
698
|
+
</svg>
|
|
699
|
+
</button>
|
|
700
|
+
</span>
|
|
701
|
+
}
|
|
702
|
+
<input
|
|
703
|
+
#textInput
|
|
704
|
+
type="text"
|
|
705
|
+
[class]="inputClasses()"
|
|
706
|
+
[attr.tabindex]="activeChipIndex() === null ? 0 : -1"
|
|
707
|
+
[attr.placeholder]="placeholder() || null"
|
|
708
|
+
[attr.aria-required]="required() || null"
|
|
709
|
+
[attr.aria-label]="ariaLabel() || null"
|
|
710
|
+
[attr.aria-labelledby]="resolvedLabelledBy() || null"
|
|
711
|
+
[attr.aria-describedby]="resolvedDescribedBy() || null"
|
|
712
|
+
[attr.aria-invalid]="errorState() || null"
|
|
713
|
+
[attr.name]="name() || null"
|
|
714
|
+
[disabled]="disabled()"
|
|
715
|
+
(input)="onInput($event)"
|
|
716
|
+
(keydown)="onInputKeydown($event)"
|
|
717
|
+
(paste)="onPaste($event)"
|
|
718
|
+
/>
|
|
719
|
+
`, isInline: true, dependencies: [{ kind: "component", type: BadgeComponent, selector: "[twBadge]", inputs: ["color", "variant", "size", "pill", "dismissible", "live", "dismissLabel"], outputs: ["dismissed"], exportAs: ["twBadge"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
720
|
+
}
|
|
721
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: TagsInputComponent, decorators: [{
|
|
722
|
+
type: Component,
|
|
723
|
+
args: [{
|
|
724
|
+
selector: 'tw-tags-input',
|
|
725
|
+
exportAs: 'twTagsInput',
|
|
726
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
727
|
+
imports: [BadgeComponent],
|
|
728
|
+
providers: [
|
|
729
|
+
{
|
|
730
|
+
provide: TW_FORM_FIELD_CONTROL,
|
|
731
|
+
useExisting: forwardRef(() => TagsInputComponent),
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
template: `
|
|
735
|
+
@for (chip of chipViews(); track chip.index) {
|
|
736
|
+
<span twBadge variant="soft" [color]="color()" [size]="size()" [class]="chipClasses()">
|
|
737
|
+
<span [class]="chipLabelClasses()">{{ chip.label }}</span>
|
|
738
|
+
<button
|
|
739
|
+
#removeBtn
|
|
740
|
+
type="button"
|
|
741
|
+
[class]="removeButtonClasses()"
|
|
742
|
+
[attr.tabindex]="activeChipIndex() === chip.index ? 0 : -1"
|
|
743
|
+
[attr.aria-label]="chip.removeLabel"
|
|
744
|
+
[disabled]="disabled()"
|
|
745
|
+
(click)="onRemoveClick(chip.index)"
|
|
746
|
+
(keydown)="onChipKeydown($event, chip.index)"
|
|
747
|
+
>
|
|
748
|
+
<svg
|
|
749
|
+
viewBox="0 0 16 16"
|
|
750
|
+
fill="currentColor"
|
|
751
|
+
aria-hidden="true"
|
|
752
|
+
[class]="removeIconClasses()"
|
|
753
|
+
>
|
|
754
|
+
<path
|
|
755
|
+
d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z"
|
|
756
|
+
/>
|
|
757
|
+
</svg>
|
|
758
|
+
</button>
|
|
759
|
+
</span>
|
|
760
|
+
}
|
|
761
|
+
<input
|
|
762
|
+
#textInput
|
|
763
|
+
type="text"
|
|
764
|
+
[class]="inputClasses()"
|
|
765
|
+
[attr.tabindex]="activeChipIndex() === null ? 0 : -1"
|
|
766
|
+
[attr.placeholder]="placeholder() || null"
|
|
767
|
+
[attr.aria-required]="required() || null"
|
|
768
|
+
[attr.aria-label]="ariaLabel() || null"
|
|
769
|
+
[attr.aria-labelledby]="resolvedLabelledBy() || null"
|
|
770
|
+
[attr.aria-describedby]="resolvedDescribedBy() || null"
|
|
771
|
+
[attr.aria-invalid]="errorState() || null"
|
|
772
|
+
[attr.name]="name() || null"
|
|
773
|
+
[disabled]="disabled()"
|
|
774
|
+
(input)="onInput($event)"
|
|
775
|
+
(keydown)="onInputKeydown($event)"
|
|
776
|
+
(paste)="onPaste($event)"
|
|
777
|
+
/>
|
|
778
|
+
`,
|
|
779
|
+
host: {
|
|
780
|
+
'role': 'group',
|
|
781
|
+
'[id]': 'id()',
|
|
782
|
+
'[class]': 'rootClasses()',
|
|
783
|
+
'[attr.aria-label]': 'ariaLabel() || null',
|
|
784
|
+
'[attr.aria-labelledby]': 'resolvedLabelledBy() || null',
|
|
785
|
+
'[attr.aria-describedby]': 'resolvedDescribedBy() || null',
|
|
786
|
+
'[attr.aria-disabled]': 'disabled() || null',
|
|
787
|
+
'[attr.aria-invalid]': 'errorState() || null',
|
|
788
|
+
'[attr.data-focused]': 'focused() || null',
|
|
789
|
+
},
|
|
790
|
+
}]
|
|
791
|
+
}], ctorParameters: () => [], propDecorators: { color: [{ type: i0.Input, args: [{ isSignal: true, alias: "color", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], disabledInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], requiredInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], separatorKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "separatorKeys", required: false }] }], addOnBlur: [{ type: i0.Input, args: [{ isSignal: true, alias: "addOnBlur", required: false }] }], maxTags: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxTags", required: false }] }], allowDuplicates: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowDuplicates", required: false }] }], createTag: [{ type: i0.Input, args: [{ isSignal: true, alias: "createTag", required: false }] }], tagLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "tagLabel", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], idInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-label", required: false }] }], ariaLabelledby: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-labelledby", required: false }] }], ariaDescribedby: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-describedby", required: false }] }], errorStateMatcher: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorStateMatcher", required: false }] }], valueChange: [{ type: i0.Output, args: ["valueChange"] }], tagAdded: [{ type: i0.Output, args: ["tagAdded"] }], tagRemoved: [{ type: i0.Output, args: ["tagRemoved"] }], textInputRef: [{ type: i0.ViewChild, args: ['textInput', { isSignal: true }] }], removeButtons: [{ type: i0.ViewChildren, args: ['removeBtn', { isSignal: true }] }] } });
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Generated bundle index. Do not edit.
|
|
795
|
+
*/
|
|
796
|
+
|
|
797
|
+
export { TagsInputComponent };
|
|
798
|
+
//# sourceMappingURL=cdevhub-ngx-tw-tags-input.mjs.map
|