@x33025/sveltely 0.0.24 → 0.0.25

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.
@@ -0,0 +1,151 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { tick } from 'svelte';
4
+ import { Plus } from '@lucide/svelte';
5
+
6
+ type Props = {
7
+ placeholder?: string;
8
+ tags: string[];
9
+ selection?: string[];
10
+ action?: Snippet;
11
+ };
12
+
13
+ let {
14
+ placeholder = 'Add a tag...',
15
+ tags = $bindable<string[]>(),
16
+ selection = $bindable<string[] | undefined>(),
17
+ action
18
+ }: Props = $props();
19
+
20
+ let inputValue = $state('');
21
+ let showInput = $state(false);
22
+ let inputEl = $state<HTMLInputElement | null>(null);
23
+ const selectionEnabled = $derived(selection !== undefined);
24
+
25
+ const addTag = (rawValue: string) => {
26
+ const nextTag = rawValue.trim();
27
+ if (!nextTag || tags.includes(nextTag)) return;
28
+ tags = [...tags, nextTag];
29
+ };
30
+
31
+ const toggleSelected = (tag: string) => {
32
+ if (!selection) return;
33
+
34
+ if (selection.includes(tag)) {
35
+ selection = selection.filter((value) => value !== tag);
36
+ return;
37
+ }
38
+
39
+ selection = [...selection, tag];
40
+ };
41
+
42
+ const onKeydown = (event: KeyboardEvent) => {
43
+ if ((event.key === 'Enter' || event.key === ',') && inputValue.trim()) {
44
+ event.preventDefault();
45
+ addTag(inputValue);
46
+ inputValue = '';
47
+ return;
48
+ }
49
+
50
+ if (event.key === 'Escape') {
51
+ inputValue = '';
52
+ showInput = false;
53
+ return;
54
+ }
55
+
56
+ if (event.key === 'Backspace' && !inputValue && tags.length > 0) {
57
+ tags = tags.slice(0, -1);
58
+ }
59
+ };
60
+
61
+ const openInput = async () => {
62
+ showInput = true;
63
+ await tick();
64
+ inputEl?.focus();
65
+ };
66
+
67
+ const onBlur = () => {
68
+ if (!inputValue.trim()) {
69
+ showInput = false;
70
+ }
71
+ };
72
+
73
+ $effect(() => {
74
+ if (!selection) return;
75
+ const nextSelected = selection.filter((tag) => tags.includes(tag));
76
+ if (nextSelected.length !== selection.length) {
77
+ selection = nextSelected;
78
+ }
79
+ });
80
+ </script>
81
+
82
+ <div class="w-full max-w-lg">
83
+ <div class="tag-row flex flex-wrap items-center">
84
+ {#each tags as tag (tag)}
85
+ {#if selectionEnabled}
86
+ <button
87
+ type="button"
88
+ class="tag-surface inline-flex items-center gap-2"
89
+ class:tag-selected={selection?.includes(tag)}
90
+ onclick={() => toggleSelected(tag)}
91
+ aria-pressed={selection?.includes(tag)}
92
+ >
93
+ {tag}
94
+ </button>
95
+ {:else}
96
+ <span class="tag-surface inline-flex items-center gap-2">{tag}</span>
97
+ {/if}
98
+ {/each}
99
+
100
+ {#if showInput}
101
+ <input
102
+ bind:this={inputEl}
103
+ bind:value={inputValue}
104
+ class="tag-surface tag-input-field min-w-36 outline-none"
105
+ {placeholder}
106
+ onkeydown={onKeydown}
107
+ onblur={onBlur}
108
+ />
109
+ {:else}
110
+ <button
111
+ type="button"
112
+ class="tag-surface chip-input-action inline-flex items-center justify-center font-semibold"
113
+ aria-label="Add tag"
114
+ onclick={openInput}
115
+ >
116
+ <Plus style="width: var(--chip-input-font-size); height: var(--chip-input-font-size);" />
117
+ </button>
118
+ {/if}
119
+
120
+ {#if action}
121
+ {@render action()}
122
+ {/if}
123
+ </div>
124
+ </div>
125
+
126
+ <style>
127
+ .tag-row {
128
+ gap: var(--chip-input-gap);
129
+ }
130
+
131
+ .tag-surface {
132
+ background: var(--chip-input-background);
133
+ color: var(--chip-input-text);
134
+ font-size: var(--chip-input-font-size);
135
+ border-radius: var(--chip-input-border-radius);
136
+ padding: var(--chip-input-padding);
137
+ border: 1px solid var(--chip-input-border-color);
138
+ }
139
+
140
+ .tag-selected {
141
+ border-color: var(--chip-input-highlight);
142
+ }
143
+
144
+ .tag-surface:hover {
145
+ background: var(--chip-input-hover);
146
+ }
147
+
148
+ .tag-input-field:hover {
149
+ background: var(--chip-input-background);
150
+ }
151
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Props = {
3
+ placeholder?: string;
4
+ tags: string[];
5
+ selection?: string[];
6
+ action?: Snippet;
7
+ };
8
+ declare const ChipInput: import("svelte").Component<Props, {}, "tags" | "selection">;
9
+ type ChipInput = ReturnType<typeof ChipInput>;
10
+ export default ChipInput;
@@ -22,8 +22,70 @@
22
22
 
23
23
  let isOpen = $state(false);
24
24
  let triggerEl = $state<HTMLElement | null>(null);
25
+ let menuEl = $state<HTMLElement | null>(null);
25
26
  let menuCoords = $state({ top: 0, left: 0 });
26
27
  let menuTransform = $state('none');
28
+ let resolvedAnchor = $state<'top' | 'bottom' | 'leading' | 'trailing'>('bottom');
29
+
30
+ type Point = { x: number; y: number };
31
+
32
+ const isInsideRect = (rect: DOMRect, x: number, y: number) =>
33
+ x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
34
+
35
+ const pointInPolygon = (point: Point, polygon: Point[]) => {
36
+ let inside = false;
37
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
38
+ const xi = polygon[i].x;
39
+ const yi = polygon[i].y;
40
+ const xj = polygon[j].x;
41
+ const yj = polygon[j].y;
42
+ const intersects =
43
+ yi > point.y !== yj > point.y &&
44
+ point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + Number.EPSILON) + xi;
45
+ if (intersects) inside = !inside;
46
+ }
47
+ return inside;
48
+ };
49
+
50
+ const getSafePolygon = (
51
+ triggerRect: DOMRect,
52
+ menuRect: DOMRect,
53
+ currentAnchor: 'top' | 'bottom' | 'leading' | 'trailing'
54
+ ): Point[] => {
55
+ if (currentAnchor === 'bottom') {
56
+ return [
57
+ { x: triggerRect.left, y: triggerRect.top },
58
+ { x: triggerRect.right, y: triggerRect.top },
59
+ { x: menuRect.right, y: menuRect.top },
60
+ { x: menuRect.left, y: menuRect.top }
61
+ ];
62
+ }
63
+
64
+ if (currentAnchor === 'top') {
65
+ return [
66
+ { x: triggerRect.left, y: triggerRect.bottom },
67
+ { x: triggerRect.right, y: triggerRect.bottom },
68
+ { x: menuRect.right, y: menuRect.bottom },
69
+ { x: menuRect.left, y: menuRect.bottom }
70
+ ];
71
+ }
72
+
73
+ if (currentAnchor === 'leading') {
74
+ return [
75
+ { x: triggerRect.right, y: triggerRect.top },
76
+ { x: triggerRect.right, y: triggerRect.bottom },
77
+ { x: menuRect.right, y: menuRect.bottom },
78
+ { x: menuRect.right, y: menuRect.top }
79
+ ];
80
+ }
81
+
82
+ return [
83
+ { x: triggerRect.left, y: triggerRect.top },
84
+ { x: triggerRect.left, y: triggerRect.bottom },
85
+ { x: menuRect.left, y: menuRect.bottom },
86
+ { x: menuRect.left, y: menuRect.top }
87
+ ];
88
+ };
27
89
 
28
90
  function open() {
29
91
  if (triggerEl) {
@@ -32,29 +94,29 @@
32
94
  const spaceBelow = window.innerHeight - rect.bottom;
33
95
  const spaceLeft = rect.left;
34
96
  const spaceRight = window.innerWidth - rect.right;
35
- let resolvedAnchor = anchor;
97
+ let nextAnchor = anchor;
36
98
 
37
99
  if (anchor === 'bottom' && spaceBelow < spaceAbove) {
38
- resolvedAnchor = 'top';
100
+ nextAnchor = 'top';
39
101
  } else if (anchor === 'top' && spaceAbove < spaceBelow) {
40
- resolvedAnchor = 'bottom';
102
+ nextAnchor = 'bottom';
41
103
  } else if (anchor === 'leading' && spaceLeft < spaceRight) {
42
- resolvedAnchor = 'trailing';
104
+ nextAnchor = 'trailing';
43
105
  } else if (anchor === 'trailing' && spaceRight < spaceLeft) {
44
- resolvedAnchor = 'leading';
106
+ nextAnchor = 'leading';
45
107
  }
108
+ resolvedAnchor = nextAnchor;
46
109
 
47
- if (resolvedAnchor === 'top' || resolvedAnchor === 'bottom') {
110
+ if (nextAnchor === 'top' || nextAnchor === 'bottom') {
48
111
  const left = align === 'left' ? rect.left + window.scrollX : rect.right + window.scrollX;
49
112
  const top =
50
- resolvedAnchor === 'bottom' ? rect.bottom + window.scrollY : rect.top + window.scrollY;
113
+ nextAnchor === 'bottom' ? rect.bottom + window.scrollY : rect.top + window.scrollY;
51
114
  menuCoords = { top, left };
52
- if (resolvedAnchor === 'top' && align === 'right')
53
- menuTransform = 'translate(-100%, -100%)';
54
- else if (resolvedAnchor === 'top') menuTransform = 'translateY(-100%)';
115
+ if (nextAnchor === 'top' && align === 'right') menuTransform = 'translate(-100%, -100%)';
116
+ else if (nextAnchor === 'top') menuTransform = 'translateY(-100%)';
55
117
  else if (align === 'right') menuTransform = 'translateX(-100%)';
56
118
  else menuTransform = 'none';
57
- } else if (resolvedAnchor === 'leading') {
119
+ } else if (nextAnchor === 'leading') {
58
120
  menuCoords = { top: rect.top + window.scrollY, left: rect.left + window.scrollX };
59
121
  menuTransform = 'translateX(-100%)';
60
122
  } else {
@@ -84,12 +146,39 @@
84
146
  close();
85
147
  }
86
148
 
149
+ function handleEscape(event: KeyboardEvent) {
150
+ if (!isOpen) return;
151
+ if (event.key === 'Escape') close();
152
+ }
153
+
154
+ function handlePointerMove(event: MouseEvent) {
155
+ if (!isOpen || !triggerEl || !menuEl) return;
156
+
157
+ const pointer = { x: event.clientX, y: event.clientY };
158
+ const triggerRect = triggerEl.getBoundingClientRect();
159
+ const menuRect = menuEl.getBoundingClientRect();
160
+ const nextSafePolygon = getSafePolygon(triggerRect, menuRect, resolvedAnchor);
161
+
162
+ if (isInsideRect(triggerRect, pointer.x, pointer.y)) return;
163
+ if (isInsideRect(menuRect, pointer.x, pointer.y)) return;
164
+
165
+ if (pointInPolygon(pointer, nextSafePolygon)) return;
166
+
167
+ close();
168
+ }
169
+
87
170
  function handleSelect() {
88
171
  if (closeOnSelect) close();
89
172
  }
90
173
  </script>
91
174
 
92
- <svelte:window onclick={handleOutsideClick} onscroll={close} onresize={close} />
175
+ <svelte:window
176
+ onclick={handleOutsideClick}
177
+ onscroll={close}
178
+ onresize={close}
179
+ onkeydown={handleEscape}
180
+ onmousemove={handlePointerMove}
181
+ />
93
182
 
94
183
  <div class="dropdown-container relative inline-block text-left {className}">
95
184
  <div bind:this={triggerEl}>
@@ -112,6 +201,7 @@
112
201
  role="menu"
113
202
  aria-orientation="vertical"
114
203
  tabindex="-1"
204
+ bind:this={menuEl}
115
205
  >
116
206
  <div
117
207
  class="overflow-auto"
package/dist/index.d.ts CHANGED
@@ -10,3 +10,4 @@ export { default as Spinner } from './components/Spinner.svelte';
10
10
  export { default as TextShimmer } from './components/TextShimmer.svelte';
11
11
  export { default as Tooltip } from './components/Tooltip.svelte';
12
12
  export { default as Dropdown } from './components/Dropdown';
13
+ export { default as ChipInput } from './components/ChipInput.svelte';
package/dist/index.js CHANGED
@@ -10,3 +10,4 @@ export { default as Spinner } from './components/Spinner.svelte';
10
10
  export { default as TextShimmer } from './components/TextShimmer.svelte';
11
11
  export { default as Tooltip } from './components/Tooltip.svelte';
12
12
  export { default as Dropdown } from './components/Dropdown';
13
+ export { default as ChipInput } from './components/ChipInput.svelte';
@@ -41,6 +41,12 @@
41
41
  height: 1px;
42
42
  width: 100%;
43
43
  }
44
+
45
+ .chip-input-action {
46
+ width: calc(var(--chip-input-font-size) + 16px);
47
+ height: calc(var(--chip-input-font-size) + 16px);
48
+ padding: 0;
49
+ }
44
50
  }
45
51
 
46
52
  @layer theme {
@@ -84,5 +90,15 @@
84
90
  0px,
85
91
  calc(var(--segmented-picker-border-radius) - var(--segmented-picker-outer-padding))
86
92
  );
93
+
94
+ --chip-input-gap: var(--spacing);
95
+ --chip-input-padding: 4px 8px;
96
+ --chip-input-background: var(--color-zinc-100);
97
+ --chip-input-hover: var(--color-zinc-200);
98
+ --chip-input-text: var(--color-black);
99
+ --chip-input-font-size: 12px;
100
+ --chip-input-border-radius: 9999px;
101
+ --chip-input-border-color: transparent;
102
+ --chip-input-highlight: var(--color-zinc-300);
87
103
  }
88
104
  }
package/dist/style.css CHANGED
@@ -7,6 +7,8 @@
7
7
  "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
8
8
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
9
9
  "Courier New", monospace;
10
+ --color-red-50: oklch(97.1% 0.013 17.38);
11
+ --color-red-700: oklch(50.5% 0.213 27.518);
10
12
  --color-gray-100: oklch(96.7% 0.003 264.542);
11
13
  --color-gray-200: oklch(92.8% 0.006 264.531);
12
14
  --color-gray-700: oklch(37.3% 0.034 259.733);
@@ -22,6 +24,7 @@
22
24
  --color-black: #000;
23
25
  --color-white: #fff;
24
26
  --spacing: 0.25rem;
27
+ --container-lg: 32rem;
25
28
  --text-sm: 0.875rem;
26
29
  --text-sm--line-height: calc(1.25 / 0.875);
27
30
  --font-weight-medium: 500;
@@ -236,6 +239,9 @@
236
239
  .inline-block {
237
240
  display: inline-block;
238
241
  }
242
+ .inline-flex {
243
+ display: inline-flex;
244
+ }
239
245
  .size-4 {
240
246
  width: calc(var(--spacing) * 4);
241
247
  height: calc(var(--spacing) * 4);
@@ -265,6 +271,12 @@
265
271
  .w-full {
266
272
  width: 100%;
267
273
  }
274
+ .max-w-lg {
275
+ max-width: var(--container-lg);
276
+ }
277
+ .min-w-36 {
278
+ min-width: calc(var(--spacing) * 36);
279
+ }
268
280
  .flex-1 {
269
281
  flex: 1;
270
282
  }
@@ -280,9 +292,15 @@
280
292
  .flex-col {
281
293
  flex-direction: column;
282
294
  }
295
+ .flex-wrap {
296
+ flex-wrap: wrap;
297
+ }
283
298
  .items-center {
284
299
  align-items: center;
285
300
  }
301
+ .justify-center {
302
+ justify-content: center;
303
+ }
286
304
  .gap-2 {
287
305
  gap: calc(var(--spacing) * 2);
288
306
  }
@@ -305,6 +323,9 @@
305
323
  .rounded {
306
324
  border-radius: 0.25rem;
307
325
  }
326
+ .rounded-\[var\(--chip-input-border-radius\)\] {
327
+ border-radius: var(--chip-input-border-radius);
328
+ }
308
329
  .rounded-full {
309
330
  border-radius: calc(infinity * 1px);
310
331
  }
@@ -328,6 +349,9 @@
328
349
  border-left-style: var(--tw-border-style);
329
350
  border-left-width: 1px;
330
351
  }
352
+ .border-\[var\(--chip-input-border-color\)\] {
353
+ border-color: var(--chip-input-border-color);
354
+ }
331
355
  .border-gray-200 {
332
356
  border-color: var(--color-gray-200);
333
357
  }
@@ -340,6 +364,9 @@
340
364
  .border-zinc-200 {
341
365
  border-color: var(--color-zinc-200);
342
366
  }
367
+ .bg-\[var\(--chip-input-background\)\] {
368
+ background-color: var(--chip-input-background);
369
+ }
343
370
  .bg-white {
344
371
  background-color: var(--color-white);
345
372
  }
@@ -389,9 +416,15 @@
389
416
  --tw-tracking: var(--tracking-wide);
390
417
  letter-spacing: var(--tracking-wide);
391
418
  }
419
+ .text-\[var\(--chip-input-text\)\] {
420
+ color: var(--chip-input-text);
421
+ }
392
422
  .text-gray-700 {
393
423
  color: var(--color-gray-700);
394
424
  }
425
+ .text-red-700 {
426
+ color: var(--color-red-700);
427
+ }
395
428
  .text-zinc-500 {
396
429
  color: var(--color-zinc-500);
397
430
  }
@@ -468,6 +501,17 @@
468
501
  --tw-ease: var(--ease-in-out);
469
502
  transition-timing-function: var(--ease-in-out);
470
503
  }
504
+ .outline-none {
505
+ --tw-outline-style: none;
506
+ outline-style: none;
507
+ }
508
+ .hover\:bg-\[var\(--chip-input-hover\)\] {
509
+ &:hover {
510
+ @media (hover: hover) {
511
+ background-color: var(--chip-input-hover);
512
+ }
513
+ }
514
+ }
471
515
  .hover\:bg-gray-100 {
472
516
  &:hover {
473
517
  @media (hover: hover) {
@@ -475,6 +519,13 @@
475
519
  }
476
520
  }
477
521
  }
522
+ .hover\:bg-red-50 {
523
+ &:hover {
524
+ @media (hover: hover) {
525
+ background-color: var(--color-red-50);
526
+ }
527
+ }
528
+ }
478
529
  .hover\:bg-zinc-100 {
479
530
  &:hover {
480
531
  @media (hover: hover) {
@@ -488,6 +539,16 @@
488
539
  outline-style: none;
489
540
  }
490
541
  }
542
+ .disabled\:cursor-not-allowed {
543
+ &:disabled {
544
+ cursor: not-allowed;
545
+ }
546
+ }
547
+ .disabled\:opacity-40 {
548
+ &:disabled {
549
+ opacity: 40%;
550
+ }
551
+ }
491
552
  }
492
553
  @layer base {
493
554
  html, body {
@@ -535,6 +596,11 @@
535
596
  height: 1px;
536
597
  width: 100%;
537
598
  }
599
+ .chip-input-action {
600
+ width: calc(var(--chip-input-font-size) + 16px);
601
+ height: calc(var(--chip-input-font-size) + 16px);
602
+ padding: 0;
603
+ }
538
604
  }
539
605
  @layer theme {
540
606
  :root {
@@ -573,6 +639,15 @@
573
639
  0px,
574
640
  calc(var(--segmented-picker-border-radius) - var(--segmented-picker-outer-padding))
575
641
  );
642
+ --chip-input-gap: var(--spacing);
643
+ --chip-input-padding: 4px 8px;
644
+ --chip-input-background: var(--color-zinc-100);
645
+ --chip-input-hover: var(--color-zinc-200);
646
+ --chip-input-text: var(--color-black);
647
+ --chip-input-font-size: 12px;
648
+ --chip-input-border-radius: 9999px;
649
+ --chip-input-border-color: transparent;
650
+ --chip-input-highlight: var(--color-zinc-300);
576
651
  }
577
652
  }
578
653
  @property --tw-rotate-x {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x33025/sveltely",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",