@stnd/ui 0.5.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.
@@ -0,0 +1,458 @@
1
+ <script>
2
+ import { setContext } from "svelte";
3
+
4
+ /**
5
+ * @typedef {Object} ComboboxOption
6
+ * @property {string} label
7
+ * @property {string} value
8
+ */
9
+
10
+ /**
11
+ * @typedef {Object} ComboboxProps
12
+ * @property {ComboboxOption[]} options
13
+ * @property {string} [value]
14
+ * @property {string} [placeholder]
15
+ * @property {string} [searchPlaceholder]
16
+ * @property {string} [emptyText]
17
+ * @property {boolean} [allowCustom]
18
+ * @property {string} [class]
19
+ * @property {string} [name]
20
+ * @property {string} [id]
21
+ */
22
+
23
+ /** @type {ComboboxProps} */
24
+ let {
25
+ options = [],
26
+ value = $bindable(""),
27
+ placeholder = "Sélectionnez une option...",
28
+ searchPlaceholder = "Rechercher...",
29
+ emptyText = "Aucun résultat trouvé.",
30
+ allowCustom = false,
31
+ class: className = "",
32
+ name = "",
33
+ id = "",
34
+ // Preview-related options (optional)
35
+ preview = false,
36
+ previewOnHover = true,
37
+ previewOnSelect = true,
38
+ // Optional callback: parent can pass a function to load a font (e.g. loadGoogleFont)
39
+ loadFont = null,
40
+ // Debounce delay (ms) before invoking preview loader on hover
41
+ previewDebounce = 100,
42
+ ...props
43
+ } = $props();
44
+
45
+ let open = $state(false);
46
+ let searchQuery = $state("");
47
+ let containerElement = $state();
48
+ let inputElement = $state();
49
+
50
+ // Preview helpers (font previewing / lazy loading)
51
+ let _previewTimer = null;
52
+
53
+ function cancelPreview() {
54
+ if (_previewTimer) {
55
+ clearTimeout(_previewTimer);
56
+ _previewTimer = null;
57
+ }
58
+ }
59
+
60
+ function triggerPreview(optValue) {
61
+ if (!preview) return;
62
+ // call provided loader callback if available
63
+ try {
64
+ if (typeof loadFont === "function") {
65
+ loadFont(optValue);
66
+ }
67
+ } catch (err) {
68
+ // swallow loader errors
69
+ }
70
+ // Dispatch a DOM-level preview event for parents to listen if desired
71
+ if (containerElement) {
72
+ containerElement.dispatchEvent(
73
+ new CustomEvent("preview", {
74
+ detail: { value: optValue },
75
+ bubbles: true,
76
+ }),
77
+ );
78
+ }
79
+ }
80
+
81
+ function schedulePreview(optValue) {
82
+ cancelPreview();
83
+ if (!preview || !previewOnHover) return;
84
+ _previewTimer = setTimeout(() => {
85
+ triggerPreview(optValue);
86
+ _previewTimer = null;
87
+ }, previewDebounce);
88
+ }
89
+
90
+ let filteredOptions = $derived.by(() => {
91
+ let filtered = options.filter((opt) =>
92
+ opt.label.toLowerCase().includes(searchQuery.toLowerCase()),
93
+ );
94
+ if (
95
+ allowCustom &&
96
+ searchQuery.trim() &&
97
+ !options.some(
98
+ (opt) =>
99
+ opt.value.toLowerCase() === searchQuery.trim().toLowerCase() ||
100
+ opt.label.toLowerCase() === searchQuery.trim().toLowerCase(),
101
+ )
102
+ ) {
103
+ filtered.push({
104
+ value: searchQuery.trim(),
105
+ label: `Ajouter "${searchQuery.trim()}"`,
106
+ isCustom: true,
107
+ });
108
+ }
109
+ return filtered;
110
+ });
111
+
112
+ let selectedOption = $derived(
113
+ options.find((opt) => opt.value === value) ||
114
+ (allowCustom && value ? { label: value, value } : undefined),
115
+ );
116
+
117
+ $effect(() => {
118
+ if (!containerElement) return;
119
+ function handleSetValue(e) {
120
+ const incoming = e.detail?.value;
121
+ if (incoming) value = incoming;
122
+ }
123
+ containerElement.addEventListener("setvalue", handleSetValue);
124
+ const initial = containerElement.getAttribute("data-initial-value");
125
+ if (initial && !value) value = initial;
126
+ return () =>
127
+ containerElement.removeEventListener("setvalue", handleSetValue);
128
+ });
129
+
130
+ function toggle() {
131
+ open = !open;
132
+ if (open) {
133
+ searchQuery = "";
134
+ setTimeout(() => inputElement?.focus(), 10);
135
+ }
136
+ }
137
+
138
+ function close() {
139
+ open = false;
140
+ }
141
+
142
+ function selectOption(optValue) {
143
+ value = optValue;
144
+ // Trigger preview-on-select if enabled
145
+ if (preview && previewOnSelect) {
146
+ try {
147
+ if (typeof loadFont === "function") {
148
+ loadFont(optValue);
149
+ }
150
+ } catch (err) {
151
+ // ignore
152
+ }
153
+ if (containerElement) {
154
+ containerElement.dispatchEvent(
155
+ new CustomEvent("preview", {
156
+ detail: { value: optValue },
157
+ bubbles: true,
158
+ }),
159
+ );
160
+ }
161
+ }
162
+
163
+ close();
164
+ cancelPreview();
165
+
166
+ if (containerElement) {
167
+ containerElement.dispatchEvent(
168
+ new CustomEvent("change", {
169
+ detail: { value: optValue },
170
+ bubbles: true,
171
+ }),
172
+ );
173
+ }
174
+ }
175
+
176
+ function handleWindowClick(e) {
177
+ if (open && containerElement && !containerElement.contains(e.target)) {
178
+ close();
179
+ }
180
+ }
181
+
182
+ function handleKeydown(e) {
183
+ if (!open) {
184
+ if (e.key === "Enter" || e.key === " ") {
185
+ e.preventDefault();
186
+ toggle();
187
+ }
188
+ return;
189
+ }
190
+
191
+ if (e.key === "Escape") {
192
+ close();
193
+ } else if (
194
+ e.key === "Enter" &&
195
+ allowCustom &&
196
+ searchQuery.trim() &&
197
+ filteredOptions.length > 0 &&
198
+ filteredOptions[0].isCustom
199
+ ) {
200
+ e.preventDefault();
201
+ selectOption(searchQuery.trim());
202
+ }
203
+ }
204
+ </script>
205
+
206
+ <svelte:window onclick={handleWindowClick} />
207
+
208
+ <div
209
+ bind:this={containerElement}
210
+ class="std-combobox {className}"
211
+ {id}
212
+ onkeydown={handleKeydown}
213
+ {...props}
214
+ >
215
+ <button
216
+ type="button"
217
+ class="std-combobox-trigger"
218
+ aria-haspopup="listbox"
219
+ aria-expanded={open}
220
+ aria-controls="combobox-listbox"
221
+ onclick={toggle}
222
+ >
223
+ <span class="truncate" class:muted={!selectedOption}>
224
+ {selectedOption ? selectedOption.label : placeholder}
225
+ </span>
226
+
227
+ <svg
228
+ xmlns="http://www.w3.org/2000/svg"
229
+ width="1em"
230
+ height="1em"
231
+ viewBox="0 0 24 24"
232
+ fill="none"
233
+ stroke="currentColor"
234
+ stroke-width="2"
235
+ stroke-linecap="round"
236
+ stroke-linejoin="round"
237
+ class="icon chevron-icon"
238
+ >
239
+ <path d="m7 15 5 5 5-5"></path>
240
+ <path d="m7 9 5-5 5 5"></path>
241
+ </svg>
242
+ </button>
243
+
244
+ {#if open}
245
+ <div
246
+ class="std-menu-content std-combobox-popover"
247
+ role="dialog"
248
+ aria-label="Options"
249
+ >
250
+ <header class="std-combobox-header">
251
+ <svg
252
+ xmlns="http://www.w3.org/2000/svg"
253
+ width="1em"
254
+ height="1em"
255
+ viewBox="0 0 24 24"
256
+ fill="none"
257
+ stroke="currentColor"
258
+ stroke-width="2"
259
+ stroke-linecap="round"
260
+ stroke-linejoin="round"
261
+ class="icon search-icon"
262
+ >
263
+ <circle cx="11" cy="11" r="8"></circle>
264
+ <path d="m21 21-4.3-4.3"></path>
265
+ </svg>
266
+ <input
267
+ bind:this={inputElement}
268
+ bind:value={searchQuery}
269
+ type="text"
270
+ placeholder={searchPlaceholder}
271
+ autocomplete="off"
272
+ autocorrect="off"
273
+ spellcheck="false"
274
+ aria-autocomplete="list"
275
+ role="combobox"
276
+ aria-expanded="true"
277
+ aria-controls="combobox-listbox"
278
+ />
279
+ </header>
280
+
281
+ <div id="combobox-listbox" class="std-combobox-listbox" role="listbox">
282
+ {#if filteredOptions.length === 0}
283
+ <div class="std-combobox-empty">{emptyText}</div>
284
+ {:else}
285
+ {#each filteredOptions as opt (opt.value)}
286
+ <button
287
+ type="button"
288
+ class="std-menu-item std-combobox-option"
289
+ class:selected={value === opt.value}
290
+ role="option"
291
+ aria-selected={value === opt.value}
292
+ onmouseenter={() => schedulePreview(opt.value)}
293
+ onmouseleave={cancelPreview}
294
+ onclick={() => selectOption(opt.value)}
295
+ >
296
+ <span class="item-label truncate">{opt.label}</span>
297
+ {#if value === opt.value}
298
+ <svg
299
+ xmlns="http://www.w3.org/2000/svg"
300
+ width="1em"
301
+ height="1em"
302
+ viewBox="0 0 24 24"
303
+ fill="none"
304
+ stroke="currentColor"
305
+ stroke-width="2"
306
+ stroke-linecap="round"
307
+ stroke-linejoin="round"
308
+ class="item-icon check-icon"
309
+ >
310
+ <polyline points="20 6 9 17 4 12"></polyline>
311
+ </svg>
312
+ {/if}
313
+ </button>
314
+ {/each}
315
+ {/if}
316
+ </div>
317
+ </div>
318
+ {/if}
319
+ {#if name}
320
+ <input type="hidden" {name} {value} />
321
+ {/if}
322
+ </div>
323
+
324
+ <style>
325
+ .std-combobox {
326
+ position: relative;
327
+ display: inline-block;
328
+ font-family: var(--font-interface);
329
+ text-box-trim: none;
330
+ }
331
+
332
+ .std-combobox-trigger {
333
+ display: inline-flex;
334
+ align-items: center;
335
+ justify-content: space-between;
336
+ width: 100%;
337
+ padding: var(--space-d4) var(--space-d4);
338
+ background: var(--color-surface);
339
+ font-weight: var(--font-weight);
340
+ font-size: 1em;
341
+ text-box-trim: none;
342
+
343
+ border: none;
344
+ border-radius: var(--radius-sm);
345
+ color: var(--color-muted);
346
+ cursor: pointer;
347
+ text-align: left;
348
+ outline: none;
349
+ transition: all var(--transition-fast);
350
+ box-shadow: var(--shadow-raised);
351
+
352
+ cursor: pointer;
353
+ text-align: left;
354
+ text-shadow: none !important;
355
+ &:hover {
356
+ background: var(--color-surface-high) !important;
357
+ }
358
+ }
359
+
360
+ .std-combobox-trigger:hover,
361
+ .std-combobox-trigger:focus {
362
+ border-color: var(--color-subtle);
363
+ background: var(--color-surface);
364
+ color: var(--color-foreground);
365
+ }
366
+
367
+ .std-combobox-trigger .truncate {
368
+ overflow: hidden;
369
+ text-overflow: ellipsis;
370
+ white-space: nowrap;
371
+ }
372
+
373
+ .std-combobox-trigger .muted {
374
+ color: var(--color-muted);
375
+ }
376
+
377
+ .std-combobox-popover {
378
+ position: absolute;
379
+ top: 100%;
380
+ left: 0;
381
+ margin-top: var(--space-d2);
382
+ min-width: 100%;
383
+ width: max-content;
384
+ max-width: 300px;
385
+ overflow: hidden;
386
+ background: var(--color-surface);
387
+ }
388
+
389
+ .std-combobox-header {
390
+ display: flex;
391
+ align-items: center;
392
+ padding: 0 var(--space-d4) var(--space-d4);
393
+ margin-bottom: var(--space-d4);
394
+ border-bottom: var(--border);
395
+ }
396
+
397
+ .std-combobox-header .search-icon {
398
+ color: var(--color-muted);
399
+ flex-shrink: 0;
400
+ }
401
+
402
+ .std-combobox-header input {
403
+ width: 100%;
404
+ background: transparent;
405
+ border: none;
406
+ outline: none;
407
+ font-family: inherit;
408
+ font-size: var(--size-sm);
409
+ box-shadow: none;
410
+ }
411
+
412
+ .std-combobox-header input::placeholder {
413
+ color: var(--color-muted);
414
+ }
415
+
416
+ .std-combobox-listbox {
417
+ max-height: 250px;
418
+
419
+ display: flex;
420
+ flex-direction: column;
421
+ gap: 0;
422
+ overflow-y: auto;
423
+ }
424
+
425
+ .std-combobox-option {
426
+ display: flex;
427
+ align-items: center;
428
+ justify-content: space-between;
429
+ width: 100%;
430
+ }
431
+
432
+ .std-combobox-option .truncate {
433
+ overflow: hidden;
434
+ text-overflow: ellipsis;
435
+ white-space: nowrap;
436
+ }
437
+
438
+ .std-combobox-empty {
439
+ text-align: center;
440
+ color: var(--color-subtle);
441
+ font-size: var(--size-sm);
442
+ padding: var(--space-d2);
443
+ }
444
+
445
+ .std-combobox-option.selected {
446
+ background: var(--color-surface-lowest);
447
+ }
448
+
449
+ .icon {
450
+ opacity: 0.5;
451
+ flex-shrink: 0;
452
+ }
453
+
454
+ .check-icon {
455
+ opacity: 1;
456
+ color: var(--color-foreground);
457
+ }
458
+ </style>
@@ -0,0 +1,120 @@
1
+ <script>
2
+ import { setContext } from "svelte";
3
+
4
+ /**
5
+ * @typedef {Object} ContextMenuProps
6
+ * @property {import('svelte').Snippet} children - The trigger element(s)
7
+ * @property {import('svelte').Snippet} [content] - The menu content
8
+ * @property {string} [class] - Optional class for the trigger wrapper
9
+ */
10
+
11
+ /** @type {ContextMenuProps} */
12
+ let { children, content, class: className = "", ...props } = $props();
13
+
14
+ let open = $state(false);
15
+ let positioned = $state(false);
16
+ let x = $state(0);
17
+ let y = $state(0);
18
+ let menuElement = $state();
19
+
20
+ function onContextMenu(e) {
21
+ if (e.ctrlKey) return;
22
+ e.preventDefault();
23
+
24
+ let targetX = e.clientX;
25
+ let targetY = e.clientY;
26
+
27
+ x = targetX;
28
+ y = targetY;
29
+ open = true;
30
+ positioned = false;
31
+
32
+ // Use a microtask/timeout to wait for render then measure
33
+ setTimeout(() => {
34
+ if (!menuElement) return;
35
+
36
+ const rect = menuElement.getBoundingClientRect();
37
+ const viewportWidth = window.innerWidth;
38
+ const viewportHeight = window.innerHeight;
39
+
40
+ if (targetX + rect.width > viewportWidth) {
41
+ targetX -= rect.width;
42
+ }
43
+ if (targetY + rect.height > viewportHeight) {
44
+ targetY -= rect.height;
45
+ }
46
+
47
+ x = targetX;
48
+ y = targetY;
49
+ positioned = true;
50
+ }, 0);
51
+ }
52
+
53
+ function close() {
54
+ open = false;
55
+ positioned = false;
56
+ }
57
+
58
+ setContext("context-menu", {
59
+ get open() {
60
+ return open;
61
+ },
62
+ close,
63
+ });
64
+
65
+ function handleWindowClick(e) {
66
+ if (open && menuElement && !menuElement.contains(e.target)) {
67
+ close();
68
+ }
69
+ }
70
+
71
+ function handleWindowKeydown(e) {
72
+ if (open && e.key === "Escape") {
73
+ close();
74
+ }
75
+ }
76
+ </script>
77
+
78
+ <svelte:window
79
+ onclick={handleWindowClick}
80
+ onkeydown={handleWindowKeydown}
81
+ onscroll={close}
82
+ />
83
+
84
+ <div
85
+ class="context-menu-trigger {className}"
86
+ oncontextmenu={onContextMenu}
87
+ role="presentation"
88
+ {...props}
89
+ >
90
+ {@render children?.()}
91
+ </div>
92
+
93
+ {#if open}
94
+ <div
95
+ bind:this={menuElement}
96
+ class="std-menu-content context-menu-content m-0 no-rhythm"
97
+ style:left="{x}px"
98
+ style:top="{y}px"
99
+ style:visibility={positioned ? "visible" : "hidden"}
100
+ role="menu"
101
+ tabindex="-1"
102
+ >
103
+ {@render content?.()}
104
+ </div>
105
+ {/if}
106
+
107
+ <style>
108
+ .context-menu-trigger {
109
+ display: contents;
110
+ }
111
+
112
+ .context-menu-content {
113
+ position: fixed;
114
+ min-width: var(--space-6);
115
+ }
116
+
117
+ :global(.context-menu-content > *) {
118
+ flex-shrink: 0;
119
+ }
120
+ </style>
@@ -0,0 +1,64 @@
1
+ <script>
2
+ import { getContext } from "svelte";
3
+ import Icon from "@stnd/icon/Icon.svelte";
4
+
5
+ /**
6
+ * @typedef {Object} ContextMenuItemProps
7
+ * @property {import('svelte').Snippet} [children] - Item label
8
+ * @property {string} [label] - Label as a string
9
+ * @property {string} [icon] - Icon name
10
+ * @property {string} [shortcut] - Shortcut text (e.g. ⌘C)
11
+ * @property {boolean} [disabled] - Disabled state
12
+ * @property {boolean} [destructive] - Destructive state
13
+ * @property {() => void} [onclick] - Click handler
14
+ */
15
+
16
+ /** @type {ContextMenuItemProps} */
17
+ let {
18
+ children,
19
+ label,
20
+ icon,
21
+ shortcut,
22
+ disabled = false,
23
+ destructive = false,
24
+ onclick,
25
+ ...props
26
+ } = $props();
27
+
28
+ const ctx = getContext("context-menu");
29
+
30
+ function handleClick(e) {
31
+ if (disabled) return;
32
+ onclick?.(e);
33
+ ctx?.close();
34
+ }
35
+ </script>
36
+
37
+ <button
38
+ type="button"
39
+ class="std-menu-item"
40
+ class:disabled
41
+ class:destructive
42
+ onclick={handleClick}
43
+ role="menuitem"
44
+ aria-disabled={disabled}
45
+ {...props}
46
+ >
47
+ {#if icon}
48
+ <div class="item-icon">
49
+ <Icon {icon} width="1rem" height="1rem" />
50
+ </div>
51
+ {/if}
52
+
53
+ <span class="item-label">
54
+ {#if children}
55
+ {@render children()}
56
+ {:else}
57
+ {label}
58
+ {/if}
59
+ </span>
60
+
61
+ {#if shortcut}
62
+ <span class="item-shortcut">{shortcut}</span>
63
+ {/if}
64
+ </button>