@marianmeres/stuic 2.60.0 → 2.62.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.
- package/dist/actions/popover/popover.svelte.js +18 -4
- package/dist/actions/tooltip/tooltip.svelte.js +15 -11
- package/dist/components/AppShell/AppShellSimple.svelte +5 -2
- package/dist/components/AssetsPreview/AssetsPreview.svelte +15 -17
- package/dist/components/AssetsPreview/AssetsPreview.svelte.d.ts +2 -4
- package/dist/components/CommandMenu/CommandMenu.svelte +145 -147
- package/dist/components/CommandMenu/README.md +6 -0
- package/dist/components/DropdownMenu/README.md +2 -2
- package/dist/components/Input/FieldOptions.svelte +255 -252
- package/dist/components/Input/FieldOptions.svelte.d.ts +2 -2
- package/dist/components/Modal/Modal.svelte +37 -48
- package/dist/components/Modal/Modal.svelte.d.ts +3 -13
- package/dist/components/Modal/README.md +17 -6
- package/dist/components/ModalDialog/ModalDialog.svelte +50 -13
- package/dist/components/ModalDialog/ModalDialog.svelte.d.ts +9 -0
- package/dist/components/ModalDialog/README.md +27 -8
- package/dist/components/Notifications/Notifications.svelte +135 -96
- package/dist/components/Notifications/notifications-icons.js +4 -4
- package/dist/components/X/X.svelte +1 -1
- package/package.json +1 -1
|
@@ -146,6 +146,18 @@ function getStringContent(content) {
|
|
|
146
146
|
}
|
|
147
147
|
return "";
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Find the appropriate container for the popover element.
|
|
151
|
+
* If the anchor is inside an open dialog (modal), append to the dialog
|
|
152
|
+
* so the popover renders in the same top layer as the modal.
|
|
153
|
+
*/
|
|
154
|
+
function getPopoverContainer(anchorEl) {
|
|
155
|
+
const dialog = anchorEl.closest("dialog[open]");
|
|
156
|
+
if (dialog) {
|
|
157
|
+
return dialog;
|
|
158
|
+
}
|
|
159
|
+
return document.body;
|
|
160
|
+
}
|
|
149
161
|
/**
|
|
150
162
|
* A Svelte action that displays a popover anchored to an element using CSS Anchor Positioning.
|
|
151
163
|
*
|
|
@@ -329,7 +341,9 @@ export function popover(anchorEl, fn) {
|
|
|
329
341
|
anchorEl.setAttribute("aria-expanded", "true");
|
|
330
342
|
const offsetValue = currentOptions.offset || "0.25rem";
|
|
331
343
|
const useAnchorPositioning = isSupported && !currentOptions.forceFallback;
|
|
332
|
-
//
|
|
344
|
+
// Get appropriate container (dialog if inside one, otherwise body)
|
|
345
|
+
// This ensures popover renders in same stacking context as modal dialogs
|
|
346
|
+
const container = getPopoverContainer(anchorEl);
|
|
333
347
|
if (useAnchorPositioning) {
|
|
334
348
|
// CSS Anchor Positioning mode
|
|
335
349
|
popoverEl = document.createElement("div");
|
|
@@ -343,7 +357,7 @@ export function popover(anchorEl, fn) {
|
|
|
343
357
|
margin: ${offsetValue};
|
|
344
358
|
`;
|
|
345
359
|
popoverEl.classList.add(...twMerge("stuic-popover", _classPopover, currentOptions.class).split(/\s/));
|
|
346
|
-
|
|
360
|
+
container.appendChild(popoverEl);
|
|
347
361
|
}
|
|
348
362
|
else {
|
|
349
363
|
// Fallback centered modal mode
|
|
@@ -351,7 +365,7 @@ export function popover(anchorEl, fn) {
|
|
|
351
365
|
backdropEl = document.createElement("div");
|
|
352
366
|
backdropEl.classList.add(...twMerge("stuic-popover-backdrop", _classBackdrop).split(/\s/));
|
|
353
367
|
backdropEl.style.cssText = `transition-duration: ${TRANSITION}ms;`;
|
|
354
|
-
|
|
368
|
+
container.appendChild(backdropEl);
|
|
355
369
|
// Backdrop click closes popover
|
|
356
370
|
if (currentOptions.closeOnClickOutside !== false) {
|
|
357
371
|
backdropEl.addEventListener("click", hide);
|
|
@@ -383,7 +397,7 @@ export function popover(anchorEl, fn) {
|
|
|
383
397
|
`;
|
|
384
398
|
popoverEl.classList.add(...twMerge("stuic-popover-fallback", _classPopover, currentOptions.class).split(/\s/));
|
|
385
399
|
wrapperEl.appendChild(popoverEl);
|
|
386
|
-
|
|
400
|
+
container.appendChild(wrapperEl);
|
|
387
401
|
// Lock body scroll in fallback (modal) mode
|
|
388
402
|
BodyScroll.lock();
|
|
389
403
|
// Click on wrapper (outside popover) closes
|
|
@@ -48,6 +48,18 @@ const POSITION_MAP = {
|
|
|
48
48
|
left: "left",
|
|
49
49
|
right: "right",
|
|
50
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* Find the appropriate container for the tooltip element.
|
|
53
|
+
* If the anchor is inside an open dialog (modal), append to the dialog
|
|
54
|
+
* so the tooltip renders in the same top layer as the modal.
|
|
55
|
+
*/
|
|
56
|
+
function getTooltipContainer(anchorEl) {
|
|
57
|
+
const dialog = anchorEl.closest("dialog[open]");
|
|
58
|
+
if (dialog) {
|
|
59
|
+
return dialog;
|
|
60
|
+
}
|
|
61
|
+
return document.body;
|
|
62
|
+
}
|
|
51
63
|
// Global tooltip configuration - allows disabling all tooltips at runtime
|
|
52
64
|
const globalTooltipConfig = $state({ enabled: true });
|
|
53
65
|
// Touch device auto-detection (runs once on first tooltip init)
|
|
@@ -164,7 +176,9 @@ export function tooltip(anchorEl, fn) {
|
|
|
164
176
|
tooltipEl.setAttribute("role", "tooltip");
|
|
165
177
|
tooltipEl.style.cssText += `position-anchor: ${anchorName}; transition-duration: ${TRANSITION}ms; position-area: ${POSITION_MAP[position] || "top"};`;
|
|
166
178
|
tooltipEl.classList.add(...twMerge("stuic-tooltip", _classTooltip).split(/\s/));
|
|
167
|
-
|
|
179
|
+
// Append to dialog if inside one, otherwise body (for proper top-layer rendering in modals)
|
|
180
|
+
const container = getTooltipContainer(anchorEl);
|
|
181
|
+
container.appendChild(tooltipEl);
|
|
168
182
|
//
|
|
169
183
|
tooltipEl.addEventListener("mouseenter", schedule_show);
|
|
170
184
|
tooltipEl.addEventListener("mouseleave", schedule_hide);
|
|
@@ -216,11 +230,6 @@ export function tooltip(anchorEl, fn) {
|
|
|
216
230
|
tooltipEl.classList.add("tt-visible");
|
|
217
231
|
on_show?.();
|
|
218
232
|
});
|
|
219
|
-
// waitForTwoRepaints().then(() => {
|
|
220
|
-
// tooltipEl.classList.add("tt-visible");
|
|
221
|
-
// this approach is nicer, but I have a suspicion of the event handlers not being destroyed properly (maybe just a hot-reload issues...)
|
|
222
|
-
// waitForTransitionEnd(tooltipEl).then(() => on_show?.());
|
|
223
|
-
// });
|
|
224
233
|
}
|
|
225
234
|
}, TIMEOUT);
|
|
226
235
|
}
|
|
@@ -239,11 +248,6 @@ export function tooltip(anchorEl, fn) {
|
|
|
239
248
|
tooltipEl.classList.remove("tt-block");
|
|
240
249
|
on_hide?.();
|
|
241
250
|
}, TRANSITION);
|
|
242
|
-
// this approach is nicer, but I have a suspicion of the event handlers not being destroyed properly (maybe just a hot-reload issues...)
|
|
243
|
-
// waitForTransitionEnd(tooltipEl).then(() => {
|
|
244
|
-
// tooltipEl.classList.remove("tt-block");
|
|
245
|
-
// on_hide?.();
|
|
246
|
-
// });
|
|
247
251
|
}, TIMEOUT);
|
|
248
252
|
}
|
|
249
253
|
// "reactive" params re/set
|
|
@@ -50,6 +50,9 @@
|
|
|
50
50
|
|
|
51
51
|
let headerHeight = $state(0);
|
|
52
52
|
|
|
53
|
+
// Safari was showing scrollbar so adding 1px extra which solves the problem
|
|
54
|
+
let topOffset = $derived(headerHeight + 1);
|
|
55
|
+
|
|
53
56
|
// pragmatic use case...
|
|
54
57
|
let mainWidth: number = $state(0);
|
|
55
58
|
setContext(APP_SHELL_SIMPLE_MAIN_WIDTH, {
|
|
@@ -77,7 +80,7 @@
|
|
|
77
80
|
bind:this={elRail}
|
|
78
81
|
data-shell="rail"
|
|
79
82
|
style:top="{headerHeight}px"
|
|
80
|
-
style:height="calc(100dvh - {
|
|
83
|
+
style:height="calc(100dvh - {topOffset}px)"
|
|
81
84
|
class={twMerge(
|
|
82
85
|
"sticky shrink-0",
|
|
83
86
|
"flex flex-col items-center",
|
|
@@ -95,7 +98,7 @@
|
|
|
95
98
|
bind:this={elAside}
|
|
96
99
|
data-shell="aside"
|
|
97
100
|
style:top="{headerHeight}px"
|
|
98
|
-
style:height="calc(100dvh - {
|
|
101
|
+
style:height="calc(100dvh - {topOffset}px)"
|
|
99
102
|
class={twMerge(
|
|
100
103
|
"sticky shrink-0",
|
|
101
104
|
"flex flex-col items-center",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
iconZoomOut,
|
|
22
22
|
} from "../../icons/index.js";
|
|
23
23
|
import { getFileTypeLabel } from "../../utils/get-file-type-label.js";
|
|
24
|
-
import {
|
|
24
|
+
import { ModalDialog } from "../ModalDialog/index.js";
|
|
25
25
|
import { createClog } from "@marianmeres/clog";
|
|
26
26
|
import { isImage } from "../../utils/is-image.js";
|
|
27
27
|
import { isPlainObject } from "../../utils/is-plain-object.js";
|
|
@@ -60,10 +60,8 @@
|
|
|
60
60
|
assets: string[] | AssetPreview[];
|
|
61
61
|
classControls?: string;
|
|
62
62
|
//
|
|
63
|
-
|
|
64
|
-
modalClassInner?: string;
|
|
63
|
+
modalClassDialog?: string;
|
|
65
64
|
modalClass?: string;
|
|
66
|
-
modalClassMain?: string;
|
|
67
65
|
//
|
|
68
66
|
/** Optional translate function */
|
|
69
67
|
t?: TranslateFn;
|
|
@@ -176,10 +174,8 @@
|
|
|
176
174
|
const clog = createClog("AssetsPreview", { color: "auto" });
|
|
177
175
|
|
|
178
176
|
let {
|
|
179
|
-
|
|
180
|
-
modalClassInner = "",
|
|
177
|
+
modalClassDialog = "",
|
|
181
178
|
modalClass = "",
|
|
182
|
-
modalClassMain = "",
|
|
183
179
|
assets: _assets,
|
|
184
180
|
t = t_default,
|
|
185
181
|
classControls = "",
|
|
@@ -192,7 +188,7 @@
|
|
|
192
188
|
(_assets ?? []).map(normalizeInput).filter(Boolean) as AssetPreviewNormalized[]
|
|
193
189
|
);
|
|
194
190
|
let previewIdx = $state<number>(0);
|
|
195
|
-
let modal:
|
|
191
|
+
let modal: ModalDialog | undefined = $state();
|
|
196
192
|
let dotTooltip: string | undefined = $state();
|
|
197
193
|
|
|
198
194
|
// Zoom state
|
|
@@ -478,13 +474,15 @@
|
|
|
478
474
|
/>
|
|
479
475
|
|
|
480
476
|
{#if assets.length}
|
|
481
|
-
<
|
|
477
|
+
<ModalDialog
|
|
482
478
|
bind:this={modal}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
479
|
+
classDialog={modalClassDialog}
|
|
480
|
+
class={twMerge(
|
|
481
|
+
"max-w-full max-h-full h-full rounded-lg",
|
|
482
|
+
"flex items-center justify-center relative",
|
|
483
|
+
"stuic-assets-preview stuic-assets-preview-open",
|
|
484
|
+
modalClass
|
|
485
|
+
)}
|
|
488
486
|
>
|
|
489
487
|
{@const previewAsset = assets?.[previewIdx]}
|
|
490
488
|
{#if previewAsset}
|
|
@@ -561,7 +559,7 @@
|
|
|
561
559
|
onclick={zoomOut}
|
|
562
560
|
disabled={zoomLevelIdx === 0}
|
|
563
561
|
aria-label={t("zoom_out")}
|
|
564
|
-
use:tooltip
|
|
562
|
+
use:tooltip={() => ({ content: t("zoom_out") })}
|
|
565
563
|
>
|
|
566
564
|
{@html iconZoomOut({ class: "size-6" })}
|
|
567
565
|
</button>
|
|
@@ -572,7 +570,7 @@
|
|
|
572
570
|
onclick={zoomIn}
|
|
573
571
|
disabled={zoomLevelIdx === ZOOM_LEVELS.length - 1}
|
|
574
572
|
aria-label={t("zoom_in")}
|
|
575
|
-
use:tooltip
|
|
573
|
+
use:tooltip={() => ({ content: t("zoom_in") })}
|
|
576
574
|
>
|
|
577
575
|
{@html iconZoomIn({ class: "size-6" })}
|
|
578
576
|
</button>
|
|
@@ -651,5 +649,5 @@
|
|
|
651
649
|
</div>
|
|
652
650
|
{/if}
|
|
653
651
|
{/if}
|
|
654
|
-
</
|
|
652
|
+
</ModalDialog>
|
|
655
653
|
{/if}
|
|
@@ -12,10 +12,8 @@ export interface AssetPreview {
|
|
|
12
12
|
export interface Props {
|
|
13
13
|
assets: string[] | AssetPreview[];
|
|
14
14
|
classControls?: string;
|
|
15
|
-
|
|
16
|
-
modalClassInner?: string;
|
|
15
|
+
modalClassDialog?: string;
|
|
17
16
|
modalClass?: string;
|
|
18
|
-
modalClassMain?: string;
|
|
19
17
|
/** Optional translate function */
|
|
20
18
|
t?: TranslateFn;
|
|
21
19
|
/** Optional delete handler - receives the current asset and its index */
|
|
@@ -32,7 +30,7 @@ declare const AssetsPreview: import("svelte").Component<Props, {
|
|
|
32
30
|
open: (index?: number) => void;
|
|
33
31
|
close: () => void;
|
|
34
32
|
visibility: () => {
|
|
35
|
-
readonly visible: boolean;
|
|
33
|
+
readonly visible: boolean | undefined;
|
|
36
34
|
};
|
|
37
35
|
setOpener: (opener?: null | HTMLElement) => void;
|
|
38
36
|
}, "">;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
import { ItemCollection, type Item } from "@marianmeres/item-collection";
|
|
3
|
-
import {
|
|
3
|
+
import { ModalDialog } from "../ModalDialog/index.js";
|
|
4
4
|
import { createClog } from "@marianmeres/clog";
|
|
5
5
|
import { FieldInput } from "../Input/index.js";
|
|
6
6
|
import { twMerge } from "../../utils/tw-merge.js";
|
|
@@ -91,9 +91,8 @@
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
let
|
|
94
|
+
let modalDialog: ModalDialog = $state()!;
|
|
95
95
|
let isFetching = $state(false);
|
|
96
|
-
let modalEl: HTMLDivElement | undefined = $state();
|
|
97
96
|
let optionsBox: HTMLDivElement | undefined = $state();
|
|
98
97
|
let activeEl: HTMLButtonElement | undefined = $state();
|
|
99
98
|
|
|
@@ -109,7 +108,7 @@
|
|
|
109
108
|
|
|
110
109
|
// scroll the active option into view
|
|
111
110
|
$effect(() => {
|
|
112
|
-
if (
|
|
111
|
+
if (modalDialog.visibility().visible && options.active?.[itemIdPropName]) {
|
|
113
112
|
activeEl = qsa(`#${btn_id(options.active[itemIdPropName])}`, optionsBox)[0] as any;
|
|
114
113
|
activeEl?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
115
114
|
activeEl?.focus();
|
|
@@ -151,11 +150,11 @@
|
|
|
151
150
|
}
|
|
152
151
|
|
|
153
152
|
export function close() {
|
|
154
|
-
|
|
153
|
+
modalDialog.close();
|
|
155
154
|
}
|
|
156
155
|
|
|
157
156
|
export function open(openerOrEvent?: null | HTMLElement | MouseEvent) {
|
|
158
|
-
|
|
157
|
+
modalDialog.open(openerOrEvent);
|
|
159
158
|
}
|
|
160
159
|
|
|
161
160
|
// internal DRY
|
|
@@ -195,7 +194,7 @@
|
|
|
195
194
|
<!-- this must be on window as we're catching any typing anywhere -->
|
|
196
195
|
<svelte:window
|
|
197
196
|
onkeydown={(e) => {
|
|
198
|
-
if (
|
|
197
|
+
if (modalDialog.visibility().visible) {
|
|
199
198
|
// arrow navigation
|
|
200
199
|
if (["ArrowDown", "ArrowUp"].includes(e.key)) {
|
|
201
200
|
e.preventDefault();
|
|
@@ -213,151 +212,150 @@
|
|
|
213
212
|
}}
|
|
214
213
|
/>
|
|
215
214
|
|
|
216
|
-
<
|
|
217
|
-
bind:this={
|
|
218
|
-
|
|
219
|
-
class="bg-transparent
|
|
220
|
-
classInner="max-w-2xl"
|
|
221
|
-
bind:el={modalEl}
|
|
215
|
+
<ModalDialog
|
|
216
|
+
bind:this={modalDialog}
|
|
217
|
+
classDialog="items-start"
|
|
218
|
+
class="w-full max-w-3xl bg-transparent pointer-events-none"
|
|
222
219
|
{noScrollLock}
|
|
223
220
|
>
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}}
|
|
231
|
-
class=""
|
|
232
|
-
>
|
|
233
|
-
<FieldInput
|
|
234
|
-
type="text"
|
|
235
|
-
name="q"
|
|
236
|
-
bind:input
|
|
237
|
-
bind:value={q}
|
|
238
|
-
class="search m-4 mb-12 shadow-xl"
|
|
239
|
-
classLabelBox="m-0"
|
|
240
|
-
autocomplete="off"
|
|
241
|
-
placeholder={searchPlaceholder ?? t("search_placeholder")}
|
|
242
|
-
classInputBoxWrap={twMerge(
|
|
243
|
-
// always look like focused
|
|
244
|
-
`border-primary border-input-accent dark:border-input-accent-dark`,
|
|
245
|
-
`ring-input-accent/20 dark:ring-input-accent-dark/20 ring-4`
|
|
246
|
-
)}
|
|
247
|
-
onkeydown={(e) => {
|
|
248
|
-
if (e.key === "Enter") {
|
|
249
|
-
e.preventDefault();
|
|
250
|
-
submit();
|
|
251
|
-
}
|
|
221
|
+
<div class="pt-0 md:pt-[20vh] w-full">
|
|
222
|
+
<form
|
|
223
|
+
class="pointer-events-auto"
|
|
224
|
+
onsubmit={(e) => {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
modalDialog.close();
|
|
252
227
|
}}
|
|
253
228
|
>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
q = "";
|
|
280
|
-
input?.focus();
|
|
281
|
-
}}
|
|
282
|
-
>
|
|
283
|
-
<X class="m-2 size-4 " />
|
|
284
|
-
</button>
|
|
285
|
-
</div>
|
|
286
|
-
{/snippet}
|
|
287
|
-
{#snippet inputBelow()}
|
|
288
|
-
{#if options.size}
|
|
289
|
-
<div
|
|
290
|
-
class={twMerge(
|
|
291
|
-
"options block space-y-1 p-1",
|
|
292
|
-
"overflow-y-auto overflow-x-hidden mb-1",
|
|
293
|
-
"border-t border-black/20",
|
|
294
|
-
"max-h-60"
|
|
295
|
-
)}
|
|
296
|
-
bind:this={optionsBox}
|
|
297
|
-
tabindex="-1"
|
|
298
|
-
>
|
|
299
|
-
{#each _normalize_and_group_options(options.items) as [_optgroup, _opts]}
|
|
300
|
-
<!-- {console.log(11111, _optgroup, _opts)} -->
|
|
301
|
-
<div class="p-1">
|
|
302
|
-
{#if _optgroup}
|
|
303
|
-
<div
|
|
304
|
-
class="text-sm capitalize opacity-50 border-b border-black/10 mb-1 p-1"
|
|
305
|
-
>
|
|
306
|
-
{_optgroup}
|
|
307
|
-
</div>
|
|
308
|
-
{/if}
|
|
309
|
-
<ul>
|
|
310
|
-
{#each _opts as item (item.id)}
|
|
311
|
-
{@const active =
|
|
312
|
-
item[itemIdPropName] === options.active?.[itemIdPropName]}
|
|
313
|
-
<!-- {@const isSelected = false} -->
|
|
314
|
-
<li class:active>
|
|
315
|
-
<button
|
|
316
|
-
class:active
|
|
317
|
-
type="button"
|
|
318
|
-
class={twMerge(
|
|
319
|
-
"no-focus-visible",
|
|
320
|
-
"text-left rounded-md py-2 px-2.5",
|
|
321
|
-
"min-w-0 w-full overflow-hidden text-ellipsis whitespace-nowrap",
|
|
322
|
-
"border border-transparent",
|
|
323
|
-
"focus:outline-0 focus:border-neutral-400 dark:focus:border-neutral-500",
|
|
324
|
-
"focus-visible:outline-0 focus-visible:ring-0",
|
|
325
|
-
"hover:border-neutral-400 dark:hover:border-neutral-500",
|
|
326
|
-
active && "bg-neutral-200 dark:bg-neutral-800",
|
|
327
|
-
classOption,
|
|
328
|
-
// active && "border-neutral-400",
|
|
329
|
-
active && classOptionActive
|
|
330
|
-
)}
|
|
331
|
-
id={btn_id(item[itemIdPropName])}
|
|
332
|
-
tabindex="-1"
|
|
333
|
-
onclick={() => {
|
|
334
|
-
_optionsColl.setActive(item);
|
|
335
|
-
submit();
|
|
336
|
-
}}
|
|
337
|
-
onkeydown={(e) => {
|
|
338
|
-
// need to handle tab here, because the tabindex="-1" is ignored
|
|
339
|
-
// in the focus-trap selectors... so, on Tab, manually focusin input
|
|
340
|
-
if (e.key === "Tab") {
|
|
341
|
-
e.preventDefault();
|
|
342
|
-
input?.focus();
|
|
343
|
-
}
|
|
344
|
-
}}
|
|
345
|
-
>
|
|
346
|
-
<!-- role="checkbox"
|
|
347
|
-
aria-checked={active} -->
|
|
348
|
-
{_renderOptionLabel(item)}
|
|
349
|
-
</button>
|
|
350
|
-
</li>
|
|
351
|
-
{/each}
|
|
352
|
-
</ul>
|
|
353
|
-
</div>
|
|
354
|
-
{/each}
|
|
229
|
+
<FieldInput
|
|
230
|
+
type="text"
|
|
231
|
+
name="q"
|
|
232
|
+
renderSize="lg"
|
|
233
|
+
bind:input
|
|
234
|
+
bind:value={q}
|
|
235
|
+
class="search m-2 shadow-xl"
|
|
236
|
+
classLabelBox="m-0"
|
|
237
|
+
autocomplete="off"
|
|
238
|
+
placeholder={searchPlaceholder ?? t("search_placeholder")}
|
|
239
|
+
classInputBoxWrap={twMerge(
|
|
240
|
+
// always look like focused
|
|
241
|
+
`border-primary border-input-accent dark:border-input-accent-dark`,
|
|
242
|
+
`ring-input-accent/20 dark:ring-input-accent-dark/20 ring-4`
|
|
243
|
+
)}
|
|
244
|
+
onkeydown={(e) => {
|
|
245
|
+
if (e.key === "Enter") {
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
submit();
|
|
248
|
+
}
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
{#snippet inputBefore()}
|
|
252
|
+
<div class="flex flex-col items-center justify-center pl-3 opacity-75">
|
|
253
|
+
{@html iconSearch({ size: 19, strokeWidth: 3 })}
|
|
355
254
|
</div>
|
|
356
|
-
{/
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
255
|
+
{/snippet}
|
|
256
|
+
{#snippet inputAfter()}
|
|
257
|
+
<div class="flex pl-2 items-center justify-center opacity-50">
|
|
258
|
+
{#if isFetching}
|
|
259
|
+
<Spinner class="w-4" />
|
|
260
|
+
{/if}
|
|
261
|
+
</div>
|
|
262
|
+
<div class="flex items-center justify-center">
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
class={twMerge(
|
|
266
|
+
"rounded m-1 opacity-75",
|
|
267
|
+
"hover:opacity-100 hover:bg-neutral-200 dark:hover:bg-neutral-800",
|
|
268
|
+
"focus-visible:opacity-100 focus-visible:outline-0",
|
|
269
|
+
"focus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-800"
|
|
270
|
+
)}
|
|
271
|
+
onclick={(e) => {
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
if (!`${q || ""}`.trim()) {
|
|
274
|
+
return modalDialog.close();
|
|
275
|
+
}
|
|
276
|
+
q = "";
|
|
277
|
+
input?.focus();
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
<X class="m-2 size-6" />
|
|
281
|
+
</button>
|
|
282
|
+
</div>
|
|
283
|
+
{/snippet}
|
|
284
|
+
{#snippet inputBelow()}
|
|
285
|
+
{#if options.size}
|
|
286
|
+
<div
|
|
287
|
+
class={twMerge(
|
|
288
|
+
"options block space-y-1 p-1",
|
|
289
|
+
"overflow-y-auto overflow-x-hidden mb-1",
|
|
290
|
+
"border-t border-black/20",
|
|
291
|
+
"max-h-60"
|
|
292
|
+
)}
|
|
293
|
+
bind:this={optionsBox}
|
|
294
|
+
tabindex="-1"
|
|
295
|
+
>
|
|
296
|
+
{#each _normalize_and_group_options(options.items) as [_optgroup, _opts]}
|
|
297
|
+
<!-- {console.log(11111, _optgroup, _opts)} -->
|
|
298
|
+
<div class="p-1">
|
|
299
|
+
{#if _optgroup}
|
|
300
|
+
<div
|
|
301
|
+
class="text-sm capitalize opacity-50 border-b border-black/10 mb-1 p-1"
|
|
302
|
+
>
|
|
303
|
+
{_optgroup}
|
|
304
|
+
</div>
|
|
305
|
+
{/if}
|
|
306
|
+
<ul>
|
|
307
|
+
{#each _opts as item (item.id)}
|
|
308
|
+
{@const active =
|
|
309
|
+
item[itemIdPropName] === options.active?.[itemIdPropName]}
|
|
310
|
+
<!-- {@const isSelected = false} -->
|
|
311
|
+
<li class:active>
|
|
312
|
+
<button
|
|
313
|
+
class:active
|
|
314
|
+
type="button"
|
|
315
|
+
class={twMerge(
|
|
316
|
+
"no-focus-visible",
|
|
317
|
+
"text-left rounded-md py-2 px-2.5",
|
|
318
|
+
"min-w-0 w-full overflow-hidden text-ellipsis whitespace-nowrap",
|
|
319
|
+
"border border-transparent",
|
|
320
|
+
"focus:outline-0 focus:border-neutral-400 dark:focus:border-neutral-500",
|
|
321
|
+
"focus-visible:outline-0 focus-visible:ring-0",
|
|
322
|
+
"hover:border-neutral-400 dark:hover:border-neutral-500",
|
|
323
|
+
active && "bg-neutral-200 dark:bg-neutral-800",
|
|
324
|
+
classOption,
|
|
325
|
+
// active && "border-neutral-400",
|
|
326
|
+
active && classOptionActive
|
|
327
|
+
)}
|
|
328
|
+
id={btn_id(item[itemIdPropName])}
|
|
329
|
+
tabindex="-1"
|
|
330
|
+
onclick={() => {
|
|
331
|
+
_optionsColl.setActive(item);
|
|
332
|
+
submit();
|
|
333
|
+
}}
|
|
334
|
+
onkeydown={(e) => {
|
|
335
|
+
// need to handle tab here, because the tabindex="-1" is ignored
|
|
336
|
+
// in the focus-trap selectors... so, on Tab, manually focusin input
|
|
337
|
+
if (e.key === "Tab") {
|
|
338
|
+
e.preventDefault();
|
|
339
|
+
input?.focus();
|
|
340
|
+
}
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
<!-- role="checkbox"
|
|
344
|
+
aria-checked={active} -->
|
|
345
|
+
{_renderOptionLabel(item)}
|
|
346
|
+
</button>
|
|
347
|
+
</li>
|
|
348
|
+
{/each}
|
|
349
|
+
</ul>
|
|
350
|
+
</div>
|
|
351
|
+
{/each}
|
|
352
|
+
</div>
|
|
353
|
+
{/if}
|
|
354
|
+
{/snippet}
|
|
355
|
+
</FieldInput>
|
|
356
|
+
</form>
|
|
357
|
+
</div>
|
|
358
|
+
</ModalDialog>
|
|
361
359
|
|
|
362
360
|
<style>
|
|
363
361
|
.options {
|
|
@@ -87,6 +87,12 @@ A searchable command palette/menu modal for quick navigation and selection. Supp
|
|
|
87
87
|
/>
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
## Layout
|
|
91
|
+
|
|
92
|
+
The command menu uses `ModalDialog` internally with top-aligned positioning:
|
|
93
|
+
- **Mobile**: Input at top of screen with 1rem margins from edges
|
|
94
|
+
- **Desktop (md+)**: Input positioned at ~20% from top, max-width 768px, centered horizontally
|
|
95
|
+
|
|
90
96
|
## Keyboard Navigation
|
|
91
97
|
|
|
92
98
|
- **Arrow Up/Down**: Navigate options
|
|
@@ -241,7 +241,7 @@ interface DropdownMenuExpandableItem {
|
|
|
241
241
|
|
|
242
242
|
```svelte
|
|
243
243
|
<script lang="ts">
|
|
244
|
-
import {
|
|
244
|
+
import { Avatar, DropdownMenu } from 'stuic';
|
|
245
245
|
</script>
|
|
246
246
|
|
|
247
247
|
<DropdownMenu
|
|
@@ -252,7 +252,7 @@ interface DropdownMenuExpandableItem {
|
|
|
252
252
|
>
|
|
253
253
|
{#snippet trigger({ isOpen, toggle, triggerProps })}
|
|
254
254
|
<button {...triggerProps} onclick={toggle}>
|
|
255
|
-
<
|
|
255
|
+
<Avatar input="john.doe@example.com" autoColor />
|
|
256
256
|
</button>
|
|
257
257
|
{/snippet}
|
|
258
258
|
</DropdownMenu>
|