@heyramzi/ui 2.10.1 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/tags-input/index.js +1 -1
- package/dist/components/tags-input/tags.js +3 -0
- package/package.json +7 -7
- package/src/components/tags-input/index.ts +1 -0
- package/src/components/tags-input/tags-input.svelte +40 -8
- package/src/components/tags-input/tags.test.ts +11 -0
- package/src/components/tags-input/tags.ts +5 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { default as TagsInput } from "../../../src/components/tags-input/tags-input.svelte";
|
|
2
|
-
export { addTag, canCreate, cleanTags, collectOptions, editDistance, findOption, fold, matchOptions, removeTag, splitTags } from "./tags.js";
|
|
2
|
+
export { addTag, canCreate, cleanTags, collectOptions, editDistance, findOption, fold, matchOptions, removeTag, splitTags, splitTyped } from "./tags.js";
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export function splitTags(text) {
|
|
2
2
|
return text.split(/[,\n]/).map((part)=>part.trim()).filter(Boolean);
|
|
3
3
|
}
|
|
4
|
+
export function splitTyped(text) {
|
|
5
|
+
return text.split(/[\s,;]+/).filter(Boolean);
|
|
6
|
+
}
|
|
4
7
|
export function fold(text) {
|
|
5
8
|
return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[\s._-]+/g, "");
|
|
6
9
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
|
|
5
5
|
"homepage": "https://ui.heyramzi.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -383,7 +383,7 @@
|
|
|
383
383
|
}
|
|
384
384
|
},
|
|
385
385
|
"dependencies": {
|
|
386
|
-
"@dagrejs/dagre": "^3.1.
|
|
386
|
+
"@dagrejs/dagre": "^3.1.3",
|
|
387
387
|
"@shadcn-svelte/primitives": "^0.0.1",
|
|
388
388
|
"@tiptap/core": "^3.32.0",
|
|
389
389
|
"@tiptap/extension-drag-handle": "^3.32.0",
|
|
@@ -398,9 +398,9 @@
|
|
|
398
398
|
"clsx": "^2.1.1",
|
|
399
399
|
"d3-scale": "^4.0.2",
|
|
400
400
|
"d3-shape": "^3.2.0",
|
|
401
|
-
"layerchart": "^2.
|
|
401
|
+
"layerchart": "^2.9.1",
|
|
402
402
|
"markdown-it": "^15.0.3",
|
|
403
|
-
"marked": "^18.1.
|
|
403
|
+
"marked": "^18.1.2",
|
|
404
404
|
"perfect-freehand": "^1.2.3",
|
|
405
405
|
"phosphor-svelte": "^3.1.1",
|
|
406
406
|
"roughjs": "^4.6.6",
|
|
@@ -416,14 +416,14 @@
|
|
|
416
416
|
"@types/d3-shape": "^3.2.0",
|
|
417
417
|
"@types/node": "^26.6.5",
|
|
418
418
|
"@xyflow/svelte": "^1.7.0",
|
|
419
|
-
"bits-ui": "^2.20.
|
|
419
|
+
"bits-ui": "^2.20.2",
|
|
420
420
|
"jsdom": "^30.1.3",
|
|
421
421
|
"mode-watcher": "^1.1.0",
|
|
422
|
-
"svelte": "^5.57.
|
|
422
|
+
"svelte": "^5.57.3",
|
|
423
423
|
"svelte-check": "^4.7.7",
|
|
424
424
|
"svelte-sonner": "^1.2.1",
|
|
425
425
|
"typescript": "~7.0.2",
|
|
426
|
-
"vite-plus": "1.
|
|
426
|
+
"vite-plus": "1.4.0"
|
|
427
427
|
},
|
|
428
428
|
"peerDependencies": {
|
|
429
429
|
"@internationalized/date": "^3.12.4",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
matchOptions,
|
|
12
12
|
removeTag,
|
|
13
13
|
splitTags,
|
|
14
|
+
splitTyped,
|
|
14
15
|
} from "./tags.js";
|
|
15
16
|
|
|
16
17
|
// =========================
|
|
@@ -40,6 +41,11 @@
|
|
|
40
41
|
allowCreate?: boolean;
|
|
41
42
|
/** Most tags the list holds. Past it the input goes away. */
|
|
42
43
|
max?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Tags typed freely, with no options and no menu: Enter, comma, space or Tab adds what's
|
|
46
|
+
* typed, a pasted list adds every part, and leaving the field keeps what was typed.
|
|
47
|
+
*/
|
|
48
|
+
free?: boolean;
|
|
43
49
|
/** Accessible name of the field. */
|
|
44
50
|
label: string;
|
|
45
51
|
labels?: Partial<Labels>;
|
|
@@ -56,6 +62,7 @@
|
|
|
56
62
|
options = [],
|
|
57
63
|
allowCreate = true,
|
|
58
64
|
max,
|
|
65
|
+
free = false,
|
|
59
66
|
label,
|
|
60
67
|
labels,
|
|
61
68
|
disabled = false,
|
|
@@ -90,11 +97,17 @@
|
|
|
90
97
|
const matches = $derived(matchOptions(options, tags, query));
|
|
91
98
|
const creatable = $derived(canCreate(options, tags, query, allowCreate));
|
|
92
99
|
// Options closest first (a typo's fix comes before anything new), then the one row that makes what's typed.
|
|
93
|
-
const rows = $derived(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
const rows = $derived(
|
|
101
|
+
free
|
|
102
|
+
? []
|
|
103
|
+
: [
|
|
104
|
+
...matches.map((name) => ({ kind: "pick" as const, name })),
|
|
105
|
+
...(creatable ? [{ kind: "create" as const, name: query.trim() }] : []),
|
|
106
|
+
],
|
|
107
|
+
);
|
|
108
|
+
const nothing = $derived(
|
|
109
|
+
!free && editing && !full && query.trim() !== "" && rows.length === 0,
|
|
110
|
+
);
|
|
98
111
|
const open = $derived(editing && !full && (rows.length > 0 || nothing));
|
|
99
112
|
|
|
100
113
|
// =========================
|
|
@@ -117,12 +130,12 @@
|
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
// Leaving the field keeps what was typed only when it is an existing label said another way.
|
|
120
|
-
// Anything else is dropped: free text is never saved by walking away.
|
|
133
|
+
// Anything else is dropped unless the field is free: free text is never saved by walking away.
|
|
121
134
|
function stop() {
|
|
122
135
|
const typed = query;
|
|
123
136
|
query = "";
|
|
124
137
|
editing = false;
|
|
125
|
-
const match = findOption(options, typed);
|
|
138
|
+
const match = free ? typed.trim() : findOption(options, typed);
|
|
126
139
|
if (match) void commit(addTag(tags, match, options, max));
|
|
127
140
|
}
|
|
128
141
|
|
|
@@ -134,7 +147,12 @@
|
|
|
134
147
|
|
|
135
148
|
function onKey(event: KeyboardEvent) {
|
|
136
149
|
const last = rows.length - 1;
|
|
137
|
-
|
|
150
|
+
const separator = event.key === "," || (free && event.key === " ");
|
|
151
|
+
if (free && (separator || event.key === "Enter" || (event.key === "Tab" && query.trim()))) {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
if (query.trim()) add(query);
|
|
154
|
+
else if (event.key === "Enter") stop();
|
|
155
|
+
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
138
156
|
if (rows.length === 0) return;
|
|
139
157
|
event.preventDefault();
|
|
140
158
|
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
@@ -162,6 +180,7 @@
|
|
|
162
180
|
// Pasted words that are existing labels are added. The rest stay in the box to be created on purpose.
|
|
163
181
|
function onPaste(event: ClipboardEvent) {
|
|
164
182
|
const pasted = event.clipboardData?.getData("text") ?? "";
|
|
183
|
+
if (free) return pasteTyped(event, pasted);
|
|
165
184
|
if (!/[,\n]/.test(pasted)) return;
|
|
166
185
|
event.preventDefault();
|
|
167
186
|
const parts = splitTags(pasted);
|
|
@@ -173,7 +192,20 @@
|
|
|
173
192
|
);
|
|
174
193
|
}
|
|
175
194
|
|
|
195
|
+
// A free field takes a pasted list whole, one tag per part. One word pastes into the box as usual.
|
|
196
|
+
function pasteTyped(event: ClipboardEvent, pasted: string) {
|
|
197
|
+
const parts = splitTyped(pasted);
|
|
198
|
+
if (parts.length < 2) return;
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
query = "";
|
|
201
|
+
void commit(
|
|
202
|
+
parts.reduce((list, part) => addTag(list, part, options, max), tags as readonly string[]),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// WHY: Chrome fires focusout from the read-state button as editing removes it, so only the input leaving counts.
|
|
176
207
|
function onFocusOut(event: FocusEvent) {
|
|
208
|
+
if (event.target !== input) return;
|
|
177
209
|
const next = event.relatedTarget as Node | null;
|
|
178
210
|
if (editing && !(next && root?.contains(next))) stop();
|
|
179
211
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
matchOptions,
|
|
9
9
|
removeTag,
|
|
10
10
|
splitTags,
|
|
11
|
+
splitTyped,
|
|
11
12
|
} from "./tags.js";
|
|
12
13
|
|
|
13
14
|
const options = ["Airtable", "Docusign", "QuickBooks", "SharePoint", "Supabase"];
|
|
@@ -17,6 +18,16 @@ describe("tags as labels", () => {
|
|
|
17
18
|
expect(splitTags(" a, b,,\nc ,")).toEqual(["a", "b", "c"]);
|
|
18
19
|
});
|
|
19
20
|
|
|
21
|
+
// save-to-clickup, 28 Oct 2026: client domains pasted from anywhere, separated any way.
|
|
22
|
+
it("splits a free field's paste on commas, semicolons, newlines and spaces", () => {
|
|
23
|
+
expect(splitTyped("acme.com, bo.io;\nzed.co ann@x.dev")).toEqual([
|
|
24
|
+
"acme.com",
|
|
25
|
+
"bo.io",
|
|
26
|
+
"zed.co",
|
|
27
|
+
"ann@x.dev",
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
20
31
|
it("counts a transposition as one edit", () => {
|
|
21
32
|
expect(editDistance("airtbale", "airtable")).toBe(1);
|
|
22
33
|
expect(editDistance("make", "airtable")).toBeGreaterThan(2);
|
|
@@ -9,6 +9,11 @@ export function splitTags(text: string): string[] {
|
|
|
9
9
|
.filter(Boolean);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** Splits typed or pasted text for a free field: commas, semicolons, newlines and spaces all separate. */
|
|
13
|
+
export function splitTyped(text: string): string[] {
|
|
14
|
+
return text.split(/[\s,;]+/).filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
/** What two spellings are compared by: no case, no spaces, no marks ("Share Point" is "SharePoint"). */
|
|
13
18
|
export function fold(text: string): string {
|
|
14
19
|
return text
|