@x33025/sveltely 0.0.24 → 0.0.26

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,20 @@
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
+ }
50
+
51
+ .dropdown-item {
52
+ border-radius: max(0px, calc(var(--dropdown-border-radius) - var(--dropdown-content-padding)));
53
+ }
54
+
55
+ .dropdown-item:hover {
56
+ background: var(--dropdown-item-highlight);
57
+ }
44
58
  }
45
59
 
46
60
  @layer theme {
@@ -55,9 +69,9 @@
55
69
 
56
70
  --dropdown-border-radius: var(--radius-md);
57
71
  --dropdown-content-padding: calc(var(--spacing));
58
-
59
72
  --dropdown-background: var(--color-white);
60
73
  --dropdown-shadow: var(--shadow-md);
74
+ --dropdown-item-highlight: var(--color-zinc-100);
61
75
 
62
76
  --tooltip-border-radius: var(--radius-sm);
63
77
  --tooltip-padding: 4px 8px;
@@ -84,5 +98,15 @@
84
98
  0px,
85
99
  calc(var(--segmented-picker-border-radius) - var(--segmented-picker-outer-padding))
86
100
  );
101
+
102
+ --chip-input-gap: var(--spacing);
103
+ --chip-input-padding: 4px 8px;
104
+ --chip-input-background: var(--color-zinc-100);
105
+ --chip-input-hover: var(--color-zinc-200);
106
+ --chip-input-text: var(--color-black);
107
+ --chip-input-font-size: 12px;
108
+ --chip-input-border-radius: 9999px;
109
+ --chip-input-border-color: transparent;
110
+ --chip-input-highlight: var(--color-zinc-300);
87
111
  }
88
112
  }
package/dist/style.css CHANGED
@@ -7,7 +7,7 @@
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-gray-100: oklch(96.7% 0.003 264.542);
10
+ --color-red-700: oklch(50.5% 0.213 27.518);
11
11
  --color-gray-200: oklch(92.8% 0.006 264.531);
12
12
  --color-gray-700: oklch(37.3% 0.034 259.733);
13
13
  --color-gray-900: oklch(21% 0.034 264.665);
@@ -22,6 +22,7 @@
22
22
  --color-black: #000;
23
23
  --color-white: #fff;
24
24
  --spacing: 0.25rem;
25
+ --container-lg: 32rem;
25
26
  --text-sm: 0.875rem;
26
27
  --text-sm--line-height: calc(1.25 / 0.875);
27
28
  --font-weight-medium: 500;
@@ -236,6 +237,9 @@
236
237
  .inline-block {
237
238
  display: inline-block;
238
239
  }
240
+ .inline-flex {
241
+ display: inline-flex;
242
+ }
239
243
  .size-4 {
240
244
  width: calc(var(--spacing) * 4);
241
245
  height: calc(var(--spacing) * 4);
@@ -265,6 +269,12 @@
265
269
  .w-full {
266
270
  width: 100%;
267
271
  }
272
+ .max-w-lg {
273
+ max-width: var(--container-lg);
274
+ }
275
+ .min-w-36 {
276
+ min-width: calc(var(--spacing) * 36);
277
+ }
268
278
  .flex-1 {
269
279
  flex: 1;
270
280
  }
@@ -280,9 +290,15 @@
280
290
  .flex-col {
281
291
  flex-direction: column;
282
292
  }
293
+ .flex-wrap {
294
+ flex-wrap: wrap;
295
+ }
283
296
  .items-center {
284
297
  align-items: center;
285
298
  }
299
+ .justify-center {
300
+ justify-content: center;
301
+ }
286
302
  .gap-2 {
287
303
  gap: calc(var(--spacing) * 2);
288
304
  }
@@ -305,6 +321,9 @@
305
321
  .rounded {
306
322
  border-radius: 0.25rem;
307
323
  }
324
+ .rounded-\[var\(--chip-input-border-radius\)\] {
325
+ border-radius: var(--chip-input-border-radius);
326
+ }
308
327
  .rounded-full {
309
328
  border-radius: calc(infinity * 1px);
310
329
  }
@@ -328,6 +347,9 @@
328
347
  border-left-style: var(--tw-border-style);
329
348
  border-left-width: 1px;
330
349
  }
350
+ .border-\[var\(--chip-input-border-color\)\] {
351
+ border-color: var(--chip-input-border-color);
352
+ }
331
353
  .border-gray-200 {
332
354
  border-color: var(--color-gray-200);
333
355
  }
@@ -340,6 +362,9 @@
340
362
  .border-zinc-200 {
341
363
  border-color: var(--color-zinc-200);
342
364
  }
365
+ .bg-\[var\(--chip-input-background\)\] {
366
+ background-color: var(--chip-input-background);
367
+ }
343
368
  .bg-white {
344
369
  background-color: var(--color-white);
345
370
  }
@@ -389,9 +414,15 @@
389
414
  --tw-tracking: var(--tracking-wide);
390
415
  letter-spacing: var(--tracking-wide);
391
416
  }
417
+ .text-\[var\(--chip-input-text\)\] {
418
+ color: var(--chip-input-text);
419
+ }
392
420
  .text-gray-700 {
393
421
  color: var(--color-gray-700);
394
422
  }
423
+ .text-red-700 {
424
+ color: var(--color-red-700);
425
+ }
395
426
  .text-zinc-500 {
396
427
  color: var(--color-zinc-500);
397
428
  }
@@ -468,10 +499,14 @@
468
499
  --tw-ease: var(--ease-in-out);
469
500
  transition-timing-function: var(--ease-in-out);
470
501
  }
471
- .hover\:bg-gray-100 {
502
+ .outline-none {
503
+ --tw-outline-style: none;
504
+ outline-style: none;
505
+ }
506
+ .hover\:bg-\[var\(--chip-input-hover\)\] {
472
507
  &:hover {
473
508
  @media (hover: hover) {
474
- background-color: var(--color-gray-100);
509
+ background-color: var(--chip-input-hover);
475
510
  }
476
511
  }
477
512
  }
@@ -488,6 +523,16 @@
488
523
  outline-style: none;
489
524
  }
490
525
  }
526
+ .disabled\:cursor-not-allowed {
527
+ &:disabled {
528
+ cursor: not-allowed;
529
+ }
530
+ }
531
+ .disabled\:opacity-40 {
532
+ &:disabled {
533
+ opacity: 40%;
534
+ }
535
+ }
491
536
  }
492
537
  @layer base {
493
538
  html, body {
@@ -535,6 +580,17 @@
535
580
  height: 1px;
536
581
  width: 100%;
537
582
  }
583
+ .chip-input-action {
584
+ width: calc(var(--chip-input-font-size) + 16px);
585
+ height: calc(var(--chip-input-font-size) + 16px);
586
+ padding: 0;
587
+ }
588
+ .dropdown-item {
589
+ border-radius: max(0px, calc(var(--dropdown-border-radius) - var(--dropdown-content-padding)));
590
+ }
591
+ .dropdown-item:hover {
592
+ background: var(--dropdown-item-highlight);
593
+ }
538
594
  }
539
595
  @layer theme {
540
596
  :root {
@@ -551,6 +607,7 @@
551
607
  --dropdown-content-padding: calc(var(--spacing));
552
608
  --dropdown-background: var(--color-white);
553
609
  --dropdown-shadow: var(--shadow-md);
610
+ --dropdown-item-highlight: var(--color-zinc-100);
554
611
  --tooltip-border-radius: var(--radius-sm);
555
612
  --tooltip-padding: 4px 8px;
556
613
  --tooltip-font-size: 12px;
@@ -573,6 +630,15 @@
573
630
  0px,
574
631
  calc(var(--segmented-picker-border-radius) - var(--segmented-picker-outer-padding))
575
632
  );
633
+ --chip-input-gap: var(--spacing);
634
+ --chip-input-padding: 4px 8px;
635
+ --chip-input-background: var(--color-zinc-100);
636
+ --chip-input-hover: var(--color-zinc-200);
637
+ --chip-input-text: var(--color-black);
638
+ --chip-input-font-size: 12px;
639
+ --chip-input-border-radius: 9999px;
640
+ --chip-input-border-color: transparent;
641
+ --chip-input-highlight: var(--color-zinc-300);
576
642
  }
577
643
  }
578
644
  @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.26",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",