Skip to content

Commit ceb9479

Browse files
authored
feat: wrap Tagify field in nowo-tag-input custom element (#29)
Give the widget the same host API as other Nowo inputs without changing Tagify's light-DOM contract.
1 parent aa88c04 commit ceb9479

13 files changed

Lines changed: 353 additions & 157 deletions

docs/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66

77
## [Unreleased]
88

9+
### Changed
10+
11+
- **Web Component:** the form theme now renders `<nowo-tag-input>` (light DOM). `tag-input.js` defines the custom element and still initializes fields with `data-controller="nowo-tag-input"`.
12+
913

1014
## [1.1.3] - 2026-08-24
1115

docs/UPGRADING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
## Table of contents
44

5+
- [Unreleased](#unreleased)
56
- [From 1.1.2 to 1.1.3](#from-112-to-113)
67

8+
## Unreleased
9+
10+
The default form theme now wraps the field in `<nowo-tag-input>`. Include the same `tag-input.js` / `tag-input.css` assets as before. Custom theme overrides that copied `tag_input_theme.html.twig` should switch the outer `<div>` to `<nowo-tag-input>` (legacy wrappers and `data-controller="nowo-tag-input"` fields still initialize).
11+
712
## From 1.1.2 to 1.1.3
813

914
Review the [CHANGELOG](CHANGELOG.md) entry. PHP **8.2+** may now be required.

docs/USAGE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ php bin/console assets:install public
8383
<script src="{{ asset('tag-input.js', 'nowo_tag_input') }}"></script>
8484
```
8585

86+
The widget is `<nowo-tag-input>` (light DOM: native input wrapped by Tagify). Legacy `[data-nowo-tag-container="1"]` hosts still initialize.
87+
8688
## Customization
8789

8890
- `value_format`: `array` (default) or `string`
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { ensureNowoTagInputDefined } from './nowo-tag-input-element';
3+
import { TAG_NOWO_TAG_INPUT } from './tag-input-lib';
4+
5+
describe('nowo-tag-input-element', () => {
6+
afterEach(() => {
7+
vi.restoreAllMocks();
8+
document.body.innerHTML = '';
9+
});
10+
11+
it('defines the custom element once', () => {
12+
ensureNowoTagInputDefined();
13+
const defined = customElements.get(TAG_NOWO_TAG_INPUT);
14+
expect(defined).toBeDefined();
15+
ensureNowoTagInputDefined();
16+
expect(customElements.get(TAG_NOWO_TAG_INPUT)).toBe(defined);
17+
});
18+
19+
it('initializes Tagify on connectedCallback', () => {
20+
ensureNowoTagInputDefined();
21+
const el = document.createElement(TAG_NOWO_TAG_INPUT);
22+
el.innerHTML = '<input data-controller="nowo-tag-input" />';
23+
document.body.appendChild(el);
24+
const input = el.querySelector('input') as HTMLInputElement;
25+
expect(input.dataset.nowoTagInputInitialized).toBe('1');
26+
expect(el.style.display).toBe('block');
27+
});
28+
29+
it('no-ops when customElements is unavailable', () => {
30+
const original = globalThis.customElements;
31+
Object.defineProperty(globalThis, 'customElements', { configurable: true, value: undefined });
32+
expect(() => ensureNowoTagInputDefined()).not.toThrow();
33+
Object.defineProperty(globalThis, 'customElements', { configurable: true, value: original });
34+
});
35+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Autonomous custom element `<nowo-tag-input>` used by the default form theme.
3+
*/
4+
5+
import { initTagContainer, TAG_NOWO_TAG_INPUT } from './tag-input-lib';
6+
7+
export class NowoTagInputElement extends HTMLElement {
8+
constructor() {
9+
super();
10+
if (!this.style.display) {
11+
this.style.display = 'block';
12+
}
13+
}
14+
15+
connectedCallback(): void {
16+
initTagContainer(this);
17+
}
18+
}
19+
20+
let definitionRequested = false;
21+
22+
/**
23+
* Defines {@link TAG_NOWO_TAG_INPUT} once. Safe to call multiple times.
24+
*/
25+
export function ensureNowoTagInputDefined(): void {
26+
if (typeof customElements === 'undefined') {
27+
return;
28+
}
29+
if (customElements.get(TAG_NOWO_TAG_INPUT) !== undefined) {
30+
return;
31+
}
32+
if (definitionRequested) {
33+
return;
34+
}
35+
definitionRequested = true;
36+
customElements.define(TAG_NOWO_TAG_INPUT, NowoTagInputElement);
37+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* Tag input library shared by the custom element and the standalone IIFE.
3+
* Initializes Tagify on Symfony form fields enhanced with data-nowo-tag-input attributes.
4+
*/
5+
6+
import Tagify from '@yaireo/tagify';
7+
8+
import { createBundleLogger } from './logger';
9+
import type { BundleLogger } from './logger';
10+
11+
export const TAG_NOWO_TAG_INPUT = 'nowo-tag-input';
12+
export const HOST_SELECTOR = `${TAG_NOWO_TAG_INPUT}, [data-nowo-tag-container="1"]`;
13+
export const INPUT_SELECTOR =
14+
'input[data-controller*="nowo-tag-input"], textarea[data-controller*="nowo-tag-input"]';
15+
16+
export type TagifyInput = HTMLInputElement | HTMLTextAreaElement;
17+
18+
export type TagifySettings = {
19+
maxTags?: number;
20+
whitelist?: string[];
21+
pattern?: RegExp | null;
22+
duplicates?: boolean;
23+
dropdown?: {
24+
enabled: boolean;
25+
maxItems: number;
26+
closeOnSelect: boolean;
27+
highlightFirst: boolean;
28+
};
29+
placeholder?: string;
30+
};
31+
32+
let bundleLogger: BundleLogger | null = null;
33+
34+
/**
35+
* @param logger - Bundle logger used by init helpers.
36+
*/
37+
export function setBundleLogger(logger: BundleLogger): void {
38+
bundleLogger = logger;
39+
}
40+
41+
/**
42+
* @returns Active logger, or a silent fallback if the entry has not registered one.
43+
*/
44+
export function getLogger(): BundleLogger {
45+
if (bundleLogger !== null) {
46+
return bundleLogger;
47+
}
48+
49+
return createBundleLogger('tag-input');
50+
}
51+
52+
/**
53+
* @param value - Attribute/dataset flag.
54+
*/
55+
export function toBool(value: string | undefined): boolean {
56+
return value === '1' || value === 'true';
57+
}
58+
59+
/**
60+
* @param raw - JSON array of allowed tags.
61+
*/
62+
export function parseWhitelist(raw: string | undefined): string[] | undefined {
63+
if (!raw) {
64+
return undefined;
65+
}
66+
67+
try {
68+
const parsed = JSON.parse(raw) as unknown;
69+
if (!Array.isArray(parsed)) {
70+
return undefined;
71+
}
72+
73+
return parsed.filter((item): item is string => typeof item === 'string');
74+
} catch {
75+
getLogger().warn('invalid whitelist JSON', { raw });
76+
return undefined;
77+
}
78+
}
79+
80+
/**
81+
* @param raw - Regex source without delimiters.
82+
*/
83+
export function parsePattern(raw: string | undefined): RegExp | null {
84+
if (!raw) {
85+
return null;
86+
}
87+
88+
try {
89+
return new RegExp(raw);
90+
} catch {
91+
getLogger().warn('invalid pattern regex', { raw });
92+
return null;
93+
}
94+
}
95+
96+
/**
97+
* @param input - Native field Tagify wraps.
98+
*/
99+
export function buildSettings(input: TagifyInput): TagifySettings {
100+
const dataset = input.dataset;
101+
const maxTagsRaw = dataset.nowoTagInputMaxTagsValue;
102+
const settings: TagifySettings = {
103+
duplicates: toBool(dataset.nowoTagInputDuplicatesValue),
104+
dropdown: {
105+
enabled: toBool(dataset.nowoTagInputDropdownEnabledValue),
106+
maxItems: 20,
107+
closeOnSelect: true,
108+
highlightFirst: true,
109+
},
110+
};
111+
112+
if (maxTagsRaw !== undefined && maxTagsRaw !== '') {
113+
const maxTags = Number.parseInt(maxTagsRaw, 10);
114+
if (!Number.isNaN(maxTags) && maxTags > 0) {
115+
settings.maxTags = maxTags;
116+
}
117+
}
118+
119+
const whitelist = parseWhitelist(dataset.nowoTagInputWhitelistValue);
120+
if (whitelist !== undefined && whitelist.length > 0) {
121+
settings.whitelist = whitelist;
122+
}
123+
124+
const pattern = parsePattern(dataset.nowoTagInputPatternValue);
125+
if (pattern !== null) {
126+
settings.pattern = pattern;
127+
}
128+
129+
const placeholder = dataset.nowoTagInputPlaceholderValue;
130+
if (placeholder !== undefined && placeholder !== '') {
131+
settings.placeholder = placeholder;
132+
}
133+
134+
return settings;
135+
}
136+
137+
/**
138+
* @param input - Native field to enhance.
139+
*/
140+
export function initTagInput(input: TagifyInput): void {
141+
if (input.dataset.nowoTagInputInitialized === '1') {
142+
return;
143+
}
144+
145+
const settings = buildSettings(input);
146+
// Tagify mutates the input in place; the instance is owned by the host element.
147+
new Tagify(input, settings);
148+
input.dataset.nowoTagInputInitialized = '1';
149+
}
150+
151+
/**
152+
* Initialize Tagify on a host custom element or legacy wrapper.
153+
*
154+
* @param host - `<nowo-tag-input>` or `[data-nowo-tag-container]`.
155+
*/
156+
export function initTagContainer(host: HTMLElement): void {
157+
const input = host.querySelector<TagifyInput>(INPUT_SELECTOR);
158+
if (input) {
159+
initTagInput(input);
160+
}
161+
}
162+
163+
/**
164+
* Discover and initialize every tag field currently in the document.
165+
*/
166+
export function initAllTagInputs(): void {
167+
const inputs = Array.from(document.querySelectorAll<TagifyInput>(INPUT_SELECTOR));
168+
getLogger().info('initializing tag inputs', { count: inputs.length });
169+
inputs.forEach(initTagInput);
170+
}
171+
172+
let observer: MutationObserver | null = null;
173+
174+
/**
175+
* Initialize existing hosts and watch for nodes added later (Turbo / live forms).
176+
*/
177+
export function runInitAndObserve(): void {
178+
initAllTagInputs();
179+
if (observer !== null || typeof MutationObserver === 'undefined' || document.body === null) {
180+
return;
181+
}
182+
183+
observer = new MutationObserver((mutations) => {
184+
for (const mutation of mutations) {
185+
mutation.addedNodes.forEach((node) => {
186+
if (!(node instanceof HTMLElement)) {
187+
return;
188+
}
189+
if (node.matches(HOST_SELECTOR)) {
190+
initTagContainer(node);
191+
} else if (node.matches(INPUT_SELECTOR)) {
192+
initTagInput(node as TagifyInput);
193+
}
194+
node.querySelectorAll<HTMLElement>(HOST_SELECTOR).forEach(initTagContainer);
195+
node.querySelectorAll<TagifyInput>(INPUT_SELECTOR).forEach(initTagInput);
196+
});
197+
}
198+
});
199+
observer.observe(document.body, { childList: true, subtree: true });
200+
}
201+
202+
/**
203+
* Disconnect the document observer. Used by tests when resetting modules.
204+
*/
205+
export function stopObserving(): void {
206+
observer?.disconnect();
207+
observer = null;
208+
}

src/Resources/assets/src/tag-input.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
nowo-tag-input,
12
.nowo-tag-input {
3+
display: block;
24
width: 100%;
35
}
46

7+
nowo-tag-input .tagify,
58
.nowo-tag-input .tagify {
69
width: 100%;
710
}

src/Resources/assets/src/tag-input.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ function setReadyState(value: DocumentReadyState): void {
88
}
99

1010
describe('tag-input entrypoint', () => {
11-
beforeEach(() => {
11+
beforeEach(async () => {
12+
const { stopObserving } = await import('./tag-input-lib');
13+
stopObserving();
1214
vi.resetModules();
1315
document.body.innerHTML = '';
1416
setReadyState('complete');

0 commit comments

Comments
 (0)