zabi-components 7.0.1 → 8.0.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/README.md +46 -3
- package/THEMING.md +255 -15
- package/dist/atoms/ActionPanel.svelte +12 -7
- package/dist/atoms/ActionPanel.svelte.d.ts +2 -0
- package/dist/atoms/Badge.svelte +93 -56
- package/dist/atoms/Badge.svelte.d.ts +15 -3
- package/dist/atoms/Button.svelte +49 -49
- package/dist/atoms/Button.svelte.d.ts +3 -0
- package/dist/atoms/Card.svelte +31 -28
- package/dist/atoms/Card.svelte.d.ts +5 -2
- package/dist/atoms/CardContent.svelte +10 -2
- package/dist/atoms/CardContent.svelte.d.ts +2 -0
- package/dist/atoms/CardFooter.svelte +10 -2
- package/dist/atoms/CardFooter.svelte.d.ts +2 -0
- package/dist/atoms/CardHeader.svelte +10 -2
- package/dist/atoms/CardHeader.svelte.d.ts +2 -0
- package/dist/atoms/Checkbox.svelte +4 -0
- package/dist/atoms/Checkbox.svelte.d.ts +2 -0
- package/dist/atoms/CodeBlock.svelte +15 -7
- package/dist/atoms/CodeBlock.svelte.d.ts +9 -3
- package/dist/atoms/ColorPicker.svelte +19 -9
- package/dist/atoms/ColorPicker.svelte.d.ts +3 -1
- package/dist/atoms/Container.svelte +2 -3
- package/dist/atoms/Divider.svelte +4 -4
- package/dist/atoms/FeatureCard.svelte +4 -3
- package/dist/atoms/Heading.svelte +56 -27
- package/dist/atoms/Heading.svelte.d.ts +17 -4
- package/dist/atoms/IconButton.svelte +30 -37
- package/dist/atoms/Input.svelte +26 -34
- package/dist/atoms/List.svelte +10 -2
- package/dist/atoms/List.svelte.d.ts +2 -0
- package/dist/atoms/ListItem.svelte +2 -2
- package/dist/atoms/ListItemLeading.svelte +4 -2
- package/dist/atoms/ListItemLeading.svelte.d.ts +1 -1
- package/dist/atoms/OptimizedImage.svelte +11 -2
- package/dist/atoms/OptimizedImage.svelte.d.ts +2 -0
- package/dist/atoms/Progress.svelte +4 -1
- package/dist/atoms/Progress.svelte.d.ts +2 -0
- package/dist/atoms/Radio.svelte +4 -0
- package/dist/atoms/Radio.svelte.d.ts +2 -0
- package/dist/atoms/Select.svelte +65 -67
- package/dist/atoms/Select.svelte.d.ts +4 -0
- package/dist/atoms/SelectionControl.svelte +5 -1
- package/dist/atoms/SelectionControl.svelte.d.ts +2 -0
- package/dist/atoms/Skeleton.svelte +5 -3
- package/dist/atoms/Table.svelte +2 -1
- package/dist/atoms/Text.svelte +34 -5
- package/dist/atoms/Text.svelte.d.ts +7 -1
- package/dist/atoms/Textarea.svelte +7 -6
- package/dist/atoms/ThemeToggle.svelte +17 -14
- package/dist/atoms/ThemeToggle.svelte.d.ts +2 -0
- package/dist/atoms/Toast.svelte +10 -8
- package/dist/atoms/Toggle.svelte +21 -13
- package/dist/atoms/Toggle.svelte.d.ts +7 -0
- package/dist/atoms/Tooltip.svelte +89 -57
- package/dist/atoms/Tooltip.svelte.d.ts +15 -0
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/organisms/index.d.ts +1 -0
- package/dist/components/organisms/index.d.ts.map +1 -1
- package/dist/components/util/cn.d.ts +8 -0
- package/dist/components/util/cn.d.ts.map +1 -0
- package/dist/components/util/ssr-safe.d.ts +4 -4
- package/dist/components/util/ssr-safe.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -6
- package/dist/molecules/Alert.svelte +43 -64
- package/dist/molecules/Alert.svelte.d.ts +7 -1
- package/dist/molecules/ComponentDemo.svelte +10 -2
- package/dist/molecules/ComponentDemo.svelte.d.ts +2 -0
- package/dist/molecules/ContactForm.svelte +12 -2
- package/dist/molecules/ContactForm.svelte.d.ts +2 -0
- package/dist/molecules/Dropdown.svelte +40 -11
- package/dist/molecules/Dropdown.svelte.d.ts +4 -0
- package/dist/molecules/EmptyState.svelte +2 -1
- package/dist/molecules/Form.svelte +25 -14
- package/dist/molecules/Form.svelte.d.ts +13 -5
- package/dist/molecules/FormField.svelte +2 -1
- package/dist/molecules/Header.svelte +13 -3
- package/dist/molecules/Header.svelte.d.ts +2 -0
- package/dist/molecules/ImageUpload.svelte +54 -15
- package/dist/molecules/ImageUpload.svelte.d.ts +16 -5
- package/dist/molecules/Modal.svelte +110 -31
- package/dist/molecules/Modal.svelte.d.ts +9 -6
- package/dist/molecules/NavigationMenu.svelte +15 -3
- package/dist/molecules/NavigationMenu.svelte.d.ts +4 -0
- package/dist/molecules/NavigationMenuContent.svelte +36 -38
- package/dist/molecules/NavigationMenuContent.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuItem.svelte +10 -2
- package/dist/molecules/NavigationMenuItem.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuLink.svelte +10 -2
- package/dist/molecules/NavigationMenuLink.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuList.svelte +10 -2
- package/dist/molecules/NavigationMenuList.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuTrigger.svelte +16 -3
- package/dist/molecules/NavigationMenuTrigger.svelte.d.ts +2 -0
- package/dist/molecules/Page.svelte +8 -2
- package/dist/molecules/Page.svelte.d.ts +2 -0
- package/dist/molecules/PropsTable.svelte +6 -3
- package/dist/molecules/PropsTable.svelte.d.ts +2 -0
- package/dist/molecules/RadioGroup.svelte +5 -1
- package/dist/molecules/RadioGroup.svelte.d.ts +2 -0
- package/dist/molecules/Section.svelte +12 -4
- package/dist/molecules/Section.svelte.d.ts +3 -1
- package/dist/molecules/SidebarBrandHeader.svelte +13 -4
- package/dist/molecules/SidebarBrandHeader.svelte.d.ts +2 -0
- package/dist/molecules/SidebarFooter.svelte +12 -4
- package/dist/molecules/SidebarFooter.svelte.d.ts +2 -0
- package/dist/molecules/SidebarNavSection.svelte +12 -4
- package/dist/molecules/SidebarNavSection.svelte.d.ts +2 -0
- package/dist/molecules/SlideUp.svelte +85 -21
- package/dist/molecules/SlideUp.svelte.d.ts +5 -4
- package/dist/molecules/Tabs.svelte +25 -19
- package/dist/molecules/Tabs.svelte.d.ts +8 -4
- package/dist/molecules/Toaster.svelte +3 -2
- package/dist/molecules/ToasterToast.svelte +41 -10
- package/dist/molecules/ToasterToast.svelte.d.ts +2 -0
- package/dist/organisms/SidebarAccountPanel.svelte +4 -0
- package/dist/organisms/SidebarAccountPanel.svelte.d.ts +2 -0
- package/dist/organisms/SidebarNavigation.svelte +102 -59
- package/dist/organisms/SidebarNavigation.svelte.d.ts +2 -0
- package/dist/organisms/SidebarPanel.svelte +19 -11
- package/dist/organisms/SidebarPanel.svelte.d.ts +2 -0
- package/dist/organisms/SidebarShell.svelte +112 -0
- package/dist/organisms/SidebarShell.svelte.d.ts +52 -0
- package/dist/organisms/TopNavbar.svelte +88 -17
- package/dist/organisms/TopNavbar.svelte.d.ts +2 -0
- package/dist/organisms/index.d.ts +1 -0
- package/dist/organisms/index.js +1 -0
- package/dist/routes/lib/variant-utils.d.ts +26 -0
- package/dist/routes/lib/{variant-utils.ts → variant-utils.js} +15 -46
- package/dist/types/events.js +1 -0
- package/dist/types/index.js +3 -0
- package/dist/util/cn.d.ts +7 -0
- package/dist/util/cn.js +48 -0
- package/dist/util/ssr-safe.d.ts +4 -4
- package/dist/zabi-components-colors.css +3 -437
- package/dist/zabi-components-theme-dark-only.css +223 -103
- package/dist/zabi-components-theme-dark.css +223 -103
- package/dist/zabi-components-theme-only.css +347 -107
- package/dist/zabi-components-theme.css +347 -107
- package/dist/zabi-components.css +953 -843
- package/docs/lucide-icons.md +3 -1
- package/package.json +20 -25
- package/dist/lib/layout-width-tokens.d.ts +0 -17
- package/dist/lib/layout-width-tokens.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Button.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Button.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Input.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Input.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/List.d.ts +0 -3
- package/dist/lib/showcase/component-docs/List.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Modal.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Modal.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Radio.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Radio.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts +0 -3
- package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Skeleton.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Skeleton.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/_shared.d.ts +0 -12
- package/dist/lib/showcase/component-docs/_shared.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/index.d.ts +0 -4
- package/dist/lib/showcase/component-docs/index.d.ts.map +0 -1
- package/dist/lib/showcase/components-catalog.d.ts +0 -3
- package/dist/lib/showcase/components-catalog.d.ts.map +0 -1
- package/dist/lib/showcase/components-showcase-constants.d.ts +0 -89
- package/dist/lib/showcase/components-showcase-constants.d.ts.map +0 -1
- package/dist/lib/showcase/docs-sidebar-helpers.d.ts +0 -15
- package/dist/lib/showcase/docs-sidebar-helpers.d.ts.map +0 -1
- package/dist/routes/lib/focus-utils.d.ts +0 -12
- package/dist/routes/lib/focus-utils.ts +0 -110
- package/dist/routes/lib/ssr-safe.ts +0 -58
- package/scripts/fix-lucide-svelte-icon-dts.js +0 -35
|
@@ -1,27 +1,47 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onDestroy } from "svelte";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
3
4
|
import Button from "../atoms/Button.svelte";
|
|
4
5
|
import { Image } from "@lucide/svelte";
|
|
6
|
+
import { cn } from "../util/cn.js";
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
export type ImageUploadFileDetail = {
|
|
9
|
+
/** Selected file, or `null` when the image is removed. */
|
|
10
|
+
file: File | null;
|
|
11
|
+
/** Object URL used for the preview, or `null`. */
|
|
12
|
+
url: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Props = Omit<HTMLAttributes<HTMLDivElement>, "onchange" | "onclick"> & {
|
|
16
|
+
/** Extra classes for the host element. */
|
|
17
|
+
class?: string;
|
|
18
|
+
/** Preview URL; `bind:value` to read it. */
|
|
7
19
|
value?: string | null;
|
|
8
20
|
disabled?: boolean;
|
|
9
21
|
accept?: string;
|
|
10
22
|
placeholder?: string;
|
|
11
23
|
errorMessage?: string;
|
|
24
|
+
/** Native `change` event from the hidden file input. */
|
|
12
25
|
onchange?: (event: Event) => void;
|
|
26
|
+
/** Fires when the file chooser is opened. */
|
|
13
27
|
onclick?: (event: Event) => void;
|
|
14
|
-
|
|
28
|
+
/** Selected file and preview URL; `{ file: null, url: null }` on remove. */
|
|
29
|
+
onfileselect?: (detail: ImageUploadFileDetail) => void;
|
|
30
|
+
};
|
|
15
31
|
|
|
16
32
|
let {
|
|
17
|
-
|
|
33
|
+
class: className = "",
|
|
34
|
+
value = $bindable(null),
|
|
18
35
|
disabled = false,
|
|
19
36
|
accept = "image/*",
|
|
20
37
|
placeholder = "No image selected",
|
|
21
38
|
errorMessage = "",
|
|
22
|
-
|
|
39
|
+
onchange,
|
|
40
|
+
onclick,
|
|
41
|
+
onfileselect,
|
|
42
|
+
children: _children,
|
|
23
43
|
...restProps
|
|
24
|
-
} = $props
|
|
44
|
+
}: Props = $props();
|
|
25
45
|
|
|
26
46
|
let fileInput = $state<HTMLInputElement>();
|
|
27
47
|
let currentObjectUrl = $state<string | null>(null);
|
|
@@ -40,13 +60,17 @@
|
|
|
40
60
|
if (!input.files || input.files.length === 0) return;
|
|
41
61
|
|
|
42
62
|
const file = input.files[0];
|
|
63
|
+
let url: string | null = null;
|
|
43
64
|
|
|
44
65
|
if (typeof URL !== "undefined" && URL.createObjectURL) {
|
|
45
66
|
revokeCurrentObjectUrl();
|
|
46
|
-
|
|
67
|
+
url = URL.createObjectURL(file);
|
|
47
68
|
value = url;
|
|
48
69
|
currentObjectUrl = url;
|
|
49
70
|
}
|
|
71
|
+
|
|
72
|
+
onchange?.(event);
|
|
73
|
+
onfileselect?.({ file, url });
|
|
50
74
|
}
|
|
51
75
|
|
|
52
76
|
function removeImage() {
|
|
@@ -54,28 +78,38 @@
|
|
|
54
78
|
|
|
55
79
|
revokeCurrentObjectUrl();
|
|
56
80
|
value = null;
|
|
81
|
+
if (fileInput) fileInput.value = "";
|
|
82
|
+
onfileselect?.({ file: null, url: null });
|
|
57
83
|
}
|
|
58
84
|
|
|
59
|
-
function triggerFileSelect() {
|
|
85
|
+
function triggerFileSelect(event: Event) {
|
|
60
86
|
if (disabled) return;
|
|
87
|
+
onclick?.(event);
|
|
61
88
|
fileInput?.click();
|
|
62
89
|
}
|
|
63
90
|
|
|
91
|
+
function handleDropZoneKeydown(event: KeyboardEvent) {
|
|
92
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
triggerFileSelect(event);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
64
98
|
onDestroy(() => {
|
|
65
99
|
revokeCurrentObjectUrl();
|
|
66
100
|
});
|
|
67
101
|
</script>
|
|
68
102
|
|
|
69
|
-
<div class="space-y-3">
|
|
103
|
+
<div class={cn("space-y-3", className)} {...restProps}>
|
|
70
104
|
{#if value}
|
|
71
105
|
<div class="relative group">
|
|
72
106
|
<img
|
|
73
107
|
src={value}
|
|
74
108
|
alt=""
|
|
75
|
-
class="w-full h-32 min-w-64 object-cover rounded-
|
|
109
|
+
class="w-full h-32 min-w-64 object-cover rounded-container border-0"
|
|
76
110
|
/>
|
|
77
111
|
<div
|
|
78
|
-
class="absolute inset-0 border border-
|
|
112
|
+
class="absolute inset-0 border border-border-overlay bg-surface-overlay/60 backdrop-blur-md min-w-64 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity rounded-container flex items-center justify-center"
|
|
79
113
|
>
|
|
80
114
|
<div class="flex gap-2">
|
|
81
115
|
<Button
|
|
@@ -99,19 +133,18 @@
|
|
|
99
133
|
</div>
|
|
100
134
|
{:else}
|
|
101
135
|
<div
|
|
102
|
-
class="border-2 border-dashed border-input rounded-
|
|
136
|
+
class="border-2 border-dashed border-input-border rounded-container min-w-64 p-6 text-center hover:border-action-primary transition-colors {disabled
|
|
103
137
|
? 'cursor-not-allowed opacity-50'
|
|
104
138
|
: 'cursor-pointer'}"
|
|
105
139
|
onclick={triggerFileSelect}
|
|
106
140
|
role="button"
|
|
107
141
|
tabindex={disabled ? -1 : 0}
|
|
108
|
-
onkeydown={
|
|
109
|
-
e.key === "Enter" && !disabled && triggerFileSelect()}
|
|
142
|
+
onkeydown={handleDropZoneKeydown}
|
|
110
143
|
aria-disabled={disabled}
|
|
111
144
|
>
|
|
112
145
|
<div class="space-y-3">
|
|
113
146
|
<div
|
|
114
|
-
class="w-12 h-12 mx-auto bg-action-secondary rounded-
|
|
147
|
+
class="w-12 h-12 mx-auto bg-action-secondary rounded-control flex items-center justify-center"
|
|
115
148
|
>
|
|
116
149
|
<Image size={24} class="text-description" />
|
|
117
150
|
</div>
|
|
@@ -132,10 +165,16 @@
|
|
|
132
165
|
onchange={handleFileSelect}
|
|
133
166
|
{disabled}
|
|
134
167
|
class="hidden"
|
|
168
|
+
data-testid="image-upload-input"
|
|
135
169
|
/>
|
|
136
170
|
|
|
137
171
|
{#if errorMessage}
|
|
138
|
-
|
|
172
|
+
<!-- Same tinted fill + tinted border Alert uses, rather than a bare
|
|
173
|
+
step-600 outline on a transparent ground. -->
|
|
174
|
+
<div
|
|
175
|
+
class="rounded-container border bg-error-subtle border-error-border text-error-text px-3 py-2 text-sm"
|
|
176
|
+
role="alert"
|
|
177
|
+
>
|
|
139
178
|
<p class="font-medium">Image upload failed</p>
|
|
140
179
|
<p>{errorMessage}</p>
|
|
141
180
|
<p class="mt-1">Recovery action: try another file or retry upload.</p>
|
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
|
|
1
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
2
|
+
export type ImageUploadFileDetail = {
|
|
3
|
+
/** Selected file, or `null` when the image is removed. */
|
|
4
|
+
file: File | null;
|
|
5
|
+
/** Object URL used for the preview, or `null`. */
|
|
6
|
+
url: string | null;
|
|
7
|
+
};
|
|
8
|
+
type Props = Omit<HTMLAttributes<HTMLDivElement>, "onchange" | "onclick"> & {
|
|
9
|
+
/** Extra classes for the host element. */
|
|
10
|
+
class?: string;
|
|
11
|
+
/** Preview URL; `bind:value` to read it. */
|
|
2
12
|
value?: string | null;
|
|
3
13
|
disabled?: boolean;
|
|
4
14
|
accept?: string;
|
|
5
15
|
placeholder?: string;
|
|
6
16
|
errorMessage?: string;
|
|
17
|
+
/** Native `change` event from the hidden file input. */
|
|
7
18
|
onchange?: (event: Event) => void;
|
|
19
|
+
/** Fires when the file chooser is opened. */
|
|
8
20
|
onclick?: (event: Event) => void;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
children?: any;
|
|
21
|
+
/** Selected file and preview URL; `{ file: null, url: null }` on remove. */
|
|
22
|
+
onfileselect?: (detail: ImageUploadFileDetail) => void;
|
|
12
23
|
};
|
|
13
|
-
declare const ImageUpload: import("svelte").Component
|
|
24
|
+
declare const ImageUpload: import("svelte").Component<Props, {}, "value">;
|
|
14
25
|
type ImageUpload = ReturnType<typeof ImageUpload>;
|
|
15
26
|
export default ImageUpload;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
+
getFocusableElements,
|
|
4
5
|
saveFocus,
|
|
5
6
|
returnFocus,
|
|
6
7
|
focusFirstElement,
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
import CardContent from '../atoms/CardContent.svelte';
|
|
12
13
|
import CardFooter from '../atoms/CardFooter.svelte';
|
|
13
14
|
|
|
15
|
+
import { cn } from "../util/cn.js";
|
|
14
16
|
type Size = 'sm' | 'md' | 'lg';
|
|
15
17
|
|
|
16
18
|
interface Props {
|
|
@@ -18,10 +20,16 @@
|
|
|
18
20
|
title?: string;
|
|
19
21
|
description?: string;
|
|
20
22
|
size?: Size;
|
|
23
|
+
/** Render the close button even when there is no `title`. */
|
|
24
|
+
showClose?: boolean;
|
|
21
25
|
onclick?: (event: Event) => void;
|
|
22
26
|
onkeydown?: (event: Event) => void;
|
|
23
|
-
/** On the `role="dialog"`
|
|
27
|
+
/** On the `role="dialog"` panel (testing, analytics). */
|
|
24
28
|
"data-testid"?: string;
|
|
29
|
+
/** Extra classes for the dialog panel. */
|
|
30
|
+
class?: string;
|
|
31
|
+
children?: Snippet;
|
|
32
|
+
footer?: Snippet;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
let {
|
|
@@ -29,19 +37,21 @@
|
|
|
29
37
|
title = '',
|
|
30
38
|
description = '',
|
|
31
39
|
size = 'md',
|
|
40
|
+
showClose = true,
|
|
41
|
+
class: className = "",
|
|
32
42
|
onclick,
|
|
33
43
|
onkeydown,
|
|
34
44
|
"data-testid": dataTestId = undefined,
|
|
35
45
|
children,
|
|
36
46
|
footer,
|
|
37
47
|
...restProps
|
|
38
|
-
}: Props
|
|
48
|
+
}: Props = $props();
|
|
39
49
|
|
|
40
50
|
const modalTitleId = generateId('modal-title');
|
|
41
51
|
const modalDescriptionId = generateId('modal-description');
|
|
42
52
|
|
|
43
53
|
let modalContainer = $state<HTMLDivElement>();
|
|
44
|
-
let
|
|
54
|
+
let focusActive = false;
|
|
45
55
|
|
|
46
56
|
const sizeClasses = $derived(
|
|
47
57
|
{
|
|
@@ -51,13 +61,33 @@
|
|
|
51
61
|
}[size] || 'w-full md:w-[28rem]',
|
|
52
62
|
);
|
|
53
63
|
|
|
64
|
+
/** Ref-counted on `<body>` so nested Modal/SlideUp overlays share one lock. */
|
|
65
|
+
function lockBodyScroll(): () => void {
|
|
66
|
+
const body = document.body;
|
|
67
|
+
const count = Number(body.dataset.zabiScrollLock ?? '0');
|
|
68
|
+
if (count === 0) {
|
|
69
|
+
body.dataset.zabiScrollLockOverflow = body.style.overflow;
|
|
70
|
+
body.style.overflow = 'hidden';
|
|
71
|
+
}
|
|
72
|
+
body.dataset.zabiScrollLock = String(count + 1);
|
|
73
|
+
return () => {
|
|
74
|
+
const next = Number(body.dataset.zabiScrollLock ?? '1') - 1;
|
|
75
|
+
if (next <= 0) {
|
|
76
|
+
body.style.overflow = body.dataset.zabiScrollLockOverflow ?? '';
|
|
77
|
+
delete body.dataset.zabiScrollLock;
|
|
78
|
+
delete body.dataset.zabiScrollLockOverflow;
|
|
79
|
+
} else {
|
|
80
|
+
body.dataset.zabiScrollLock = String(next);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
function closeModal(event?: Event) {
|
|
55
86
|
isOpen = false;
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
87
|
+
if (focusActive) {
|
|
88
|
+
focusActive = false;
|
|
89
|
+
returnFocus();
|
|
59
90
|
}
|
|
60
|
-
returnFocus();
|
|
61
91
|
if (onclick && event) {
|
|
62
92
|
onclick(event);
|
|
63
93
|
}
|
|
@@ -67,17 +97,52 @@
|
|
|
67
97
|
const container = modalContainer;
|
|
68
98
|
if (isOpen && container) {
|
|
69
99
|
saveFocus();
|
|
70
|
-
|
|
71
|
-
|
|
100
|
+
focusActive = true;
|
|
101
|
+
const unlockScroll = lockBodyScroll();
|
|
102
|
+
const t = setTimeout(() => {
|
|
72
103
|
focusFirstElement(container);
|
|
73
104
|
}, 0);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
105
|
+
return () => {
|
|
106
|
+
clearTimeout(t);
|
|
107
|
+
unlockScroll();
|
|
108
|
+
if (focusActive) {
|
|
109
|
+
focusActive = false;
|
|
110
|
+
returnFocus();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
78
113
|
}
|
|
79
114
|
});
|
|
80
115
|
|
|
116
|
+
/** Re-queries focusables on every Tab so content added while open stays inside the trap. */
|
|
117
|
+
function handleTrapKeydown(event: KeyboardEvent) {
|
|
118
|
+
const container = modalContainer;
|
|
119
|
+
if (event.key !== 'Tab' || !container) return;
|
|
120
|
+
// A nested dialog handles its own Tab cycle.
|
|
121
|
+
const owner = (event.target as Element | null)?.closest?.('[role="dialog"]');
|
|
122
|
+
if (owner && owner !== container) return;
|
|
123
|
+
|
|
124
|
+
const focusable = getFocusableElements(container);
|
|
125
|
+
if (focusable.length === 0) {
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
container.focus();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const first = focusable[0];
|
|
131
|
+
const last = focusable[focusable.length - 1];
|
|
132
|
+
const active = document.activeElement as HTMLElement | null;
|
|
133
|
+
|
|
134
|
+
if (!active || !focusable.includes(active)) {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
first.focus();
|
|
137
|
+
} else if (event.shiftKey && active === first) {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
last.focus();
|
|
140
|
+
} else if (!event.shiftKey && active === last) {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
first.focus();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
81
146
|
function handleBackdropClick(event: Event) {
|
|
82
147
|
if (event.target === event.currentTarget) {
|
|
83
148
|
closeModal(event);
|
|
@@ -86,7 +151,9 @@
|
|
|
86
151
|
|
|
87
152
|
function handleKeydown(event: Event) {
|
|
88
153
|
const keyboardEvent = event as KeyboardEvent;
|
|
89
|
-
|
|
154
|
+
// `defaultPrevented` means a nested dialog already closed itself.
|
|
155
|
+
if (keyboardEvent.key === 'Escape' && !keyboardEvent.defaultPrevented) {
|
|
156
|
+
keyboardEvent.preventDefault();
|
|
90
157
|
closeModal(event);
|
|
91
158
|
}
|
|
92
159
|
onkeydown?.(event);
|
|
@@ -94,52 +161,64 @@
|
|
|
94
161
|
</script>
|
|
95
162
|
|
|
96
163
|
{#if isOpen}
|
|
164
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
97
165
|
<div
|
|
98
166
|
class="fixed inset-0 z-modal flex cursor-pointer items-end justify-center bg-overlay p-0 md:items-center md:p-4"
|
|
99
167
|
onclick={handleBackdropClick}
|
|
100
168
|
onkeydown={handleKeydown}
|
|
101
|
-
role="
|
|
102
|
-
aria-modal="true"
|
|
103
|
-
aria-labelledby={title ? modalTitleId : undefined}
|
|
104
|
-
aria-describedby={description ? modalDescriptionId : undefined}
|
|
105
|
-
tabindex="-1"
|
|
106
|
-
data-testid={dataTestId}
|
|
107
|
-
{...restProps}
|
|
169
|
+
role="presentation"
|
|
108
170
|
>
|
|
109
171
|
<div
|
|
110
172
|
bind:this={modalContainer}
|
|
111
|
-
class=
|
|
173
|
+
class={cn(
|
|
174
|
+
"flex max-h-[90vh] min-w-[320px] cursor-default flex-col overflow-y-auto rounded-t-overlay border border-border-overlay bg-surface-overlay p-0 shadow-lg animate-[slideUp_0.3s_ease-out] md:animate-none md:rounded-overlay",
|
|
175
|
+
sizeClasses,
|
|
176
|
+
className,
|
|
177
|
+
)}
|
|
178
|
+
role="dialog"
|
|
179
|
+
aria-modal="true"
|
|
180
|
+
aria-labelledby={title ? modalTitleId : undefined}
|
|
181
|
+
aria-describedby={description ? modalDescriptionId : undefined}
|
|
182
|
+
tabindex="-1"
|
|
183
|
+
data-testid={dataTestId}
|
|
184
|
+
onkeydown={handleTrapKeydown}
|
|
185
|
+
{...restProps}
|
|
112
186
|
>
|
|
113
|
-
|
|
114
|
-
|
|
187
|
+
<!-- The panel owns padding: Card's own p-6 would stack with the header's px-6/pt-6 and indent the title 24px past the body. -->
|
|
188
|
+
<Card variant="flat" fullWidth={false} className="bg-transparent! p-0!">
|
|
189
|
+
{#if title || description || showClose}
|
|
115
190
|
<CardHeader
|
|
116
191
|
{description}
|
|
117
192
|
descriptionId={description ? modalDescriptionId : undefined}
|
|
118
193
|
className="px-6 pt-6 pb-4"
|
|
119
194
|
>
|
|
120
|
-
{
|
|
121
|
-
|
|
195
|
+
<div class="flex items-center {title ? 'justify-between' : 'justify-end'}">
|
|
196
|
+
{#if title}
|
|
122
197
|
<h2
|
|
123
198
|
id={modalTitleId}
|
|
124
199
|
class="text-2xl font-normal leading-8 tracking-normal text-headline"
|
|
125
200
|
>
|
|
126
201
|
{title}
|
|
127
202
|
</h2>
|
|
203
|
+
{/if}
|
|
204
|
+
{#if showClose}
|
|
128
205
|
<button
|
|
129
206
|
type="button"
|
|
130
207
|
onclick={closeModal}
|
|
131
|
-
class="flex
|
|
208
|
+
class="focus-ring flex size-8 cursor-pointer items-center justify-center rounded-control text-2xl text-description transition-colors hover:bg-surface-overlay-hover hover:text-headline"
|
|
132
209
|
aria-label="Close"
|
|
133
210
|
>
|
|
134
211
|
×
|
|
135
212
|
</button>
|
|
136
|
-
|
|
137
|
-
|
|
213
|
+
{/if}
|
|
214
|
+
</div>
|
|
138
215
|
</CardHeader>
|
|
139
216
|
{/if}
|
|
140
217
|
|
|
141
218
|
{#if children}
|
|
142
|
-
<CardContent
|
|
219
|
+
<CardContent
|
|
220
|
+
className="flex-1 px-6 {title || description || showClose ? '' : 'pt-6'} {footer ? '' : 'pb-6'}"
|
|
221
|
+
>
|
|
143
222
|
{@render children?.()}
|
|
144
223
|
</CardContent>
|
|
145
224
|
{/if}
|
|
@@ -1,18 +1,21 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
1
2
|
type Size = 'sm' | 'md' | 'lg';
|
|
2
3
|
interface Props {
|
|
3
4
|
isOpen?: boolean;
|
|
4
5
|
title?: string;
|
|
5
6
|
description?: string;
|
|
6
7
|
size?: Size;
|
|
8
|
+
/** Render the close button even when there is no `title`. */
|
|
9
|
+
showClose?: boolean;
|
|
7
10
|
onclick?: (event: Event) => void;
|
|
8
11
|
onkeydown?: (event: Event) => void;
|
|
9
|
-
/** On the `role="dialog"`
|
|
12
|
+
/** On the `role="dialog"` panel (testing, analytics). */
|
|
10
13
|
"data-testid"?: string;
|
|
14
|
+
/** Extra classes for the dialog panel. */
|
|
15
|
+
class?: string;
|
|
16
|
+
children?: Snippet;
|
|
17
|
+
footer?: Snippet;
|
|
11
18
|
}
|
|
12
|
-
|
|
13
|
-
children?: any;
|
|
14
|
-
footer?: any;
|
|
15
|
-
};
|
|
16
|
-
declare const Modal: import("svelte").Component<$$ComponentProps, {}, "isOpen">;
|
|
19
|
+
declare const Modal: import("svelte").Component<Props, {}, "isOpen">;
|
|
17
20
|
type Modal = ReturnType<typeof Modal>;
|
|
18
21
|
export default Modal;
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import NavigationMenuLink from "./NavigationMenuLink.svelte";
|
|
10
10
|
|
|
11
11
|
import { generateId } from "../util/ssr-safe.js";
|
|
12
|
+
|
|
13
|
+
import { cn } from "../util/cn.js";
|
|
12
14
|
import {
|
|
13
15
|
NAVIGATION_MENU_CONTEXT_KEY,
|
|
14
16
|
navigationMenuStableInstanceId,
|
|
@@ -33,24 +35,34 @@
|
|
|
33
35
|
|
|
34
36
|
interface Props {
|
|
35
37
|
viewport?: boolean | "mobile";
|
|
38
|
+
class?: string;
|
|
39
|
+
/** @deprecated use `class`. */
|
|
36
40
|
className?: string;
|
|
37
41
|
items?: NavigationMenuItemData[];
|
|
38
42
|
children?: Snippet;
|
|
39
43
|
listClassName?: string;
|
|
40
44
|
/** Panel id / `aria-controls` prefix; pass for SSR or multiple menus. If omitted with non-empty `items`, a deterministic hash is used. */
|
|
41
45
|
menuId?: string;
|
|
46
|
+
/** Accessible name for the `<nav>` landmark. */
|
|
47
|
+
ariaLabel?: string;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
let {
|
|
45
51
|
viewport = true,
|
|
46
|
-
|
|
52
|
+
class: classAttr = "",
|
|
53
|
+
className: legacyClass = "",
|
|
47
54
|
items = [],
|
|
48
55
|
children,
|
|
49
56
|
listClassName = "",
|
|
50
57
|
menuId = "",
|
|
58
|
+
ariaLabel = "Main navigation",
|
|
51
59
|
...restProps
|
|
52
60
|
}: Props = $props();
|
|
53
61
|
|
|
62
|
+
/** `class` is the public prop; `className` is a deprecated alias.
|
|
63
|
+
* Both are merged here so existing call sites keep working. */
|
|
64
|
+
const className = $derived(cn(`${classAttr} ${legacyClass}`));
|
|
65
|
+
|
|
54
66
|
let activeItem = $state<string | null>(null);
|
|
55
67
|
let isMobile = $state(false);
|
|
56
68
|
let containerElement = $state<HTMLElement | null>(null);
|
|
@@ -104,8 +116,8 @@
|
|
|
104
116
|
|
|
105
117
|
<nav
|
|
106
118
|
bind:this={containerElement}
|
|
107
|
-
class="relative
|
|
108
|
-
aria-label=
|
|
119
|
+
class={cn("relative", className)}
|
|
120
|
+
aria-label={ariaLabel}
|
|
109
121
|
{...restProps}
|
|
110
122
|
>
|
|
111
123
|
{#if items.length > 0}
|
|
@@ -15,12 +15,16 @@ export interface NavigationMenuItemData {
|
|
|
15
15
|
}
|
|
16
16
|
interface Props {
|
|
17
17
|
viewport?: boolean | "mobile";
|
|
18
|
+
class?: string;
|
|
19
|
+
/** @deprecated use `class`. */
|
|
18
20
|
className?: string;
|
|
19
21
|
items?: NavigationMenuItemData[];
|
|
20
22
|
children?: Snippet;
|
|
21
23
|
listClassName?: string;
|
|
22
24
|
/** Panel id / `aria-controls` prefix; pass for SSR or multiple menus. If omitted with non-empty `items`, a deterministic hash is used. */
|
|
23
25
|
menuId?: string;
|
|
26
|
+
/** Accessible name for the `<nav>` landmark. */
|
|
27
|
+
ariaLabel?: string;
|
|
24
28
|
}
|
|
25
29
|
declare const NavigationMenu: import("svelte").Component<Props, {}, "">;
|
|
26
30
|
type NavigationMenu = ReturnType<typeof NavigationMenu>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from "svelte";
|
|
3
3
|
import { getContext } from "svelte";
|
|
4
|
-
import {
|
|
4
|
+
import { cn } from "../util/cn.js";
|
|
5
5
|
import {
|
|
6
6
|
NAVIGATION_MENU_CONTEXT_KEY,
|
|
7
7
|
navigationMenuPanelId,
|
|
@@ -10,19 +10,25 @@
|
|
|
10
10
|
|
|
11
11
|
interface Props {
|
|
12
12
|
value?: string;
|
|
13
|
+
class?: string;
|
|
14
|
+
/** @deprecated use `class`. */
|
|
13
15
|
className?: string;
|
|
14
16
|
children?: Snippet;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
let {
|
|
18
20
|
value = "",
|
|
19
|
-
|
|
21
|
+
class: classAttr = "",
|
|
22
|
+
className: legacyClass = "",
|
|
20
23
|
children,
|
|
21
24
|
...restProps
|
|
22
25
|
}: Props = $props();
|
|
23
26
|
|
|
27
|
+
/** `class` is the public prop; `className` is a deprecated alias.
|
|
28
|
+
* Both are merged here so existing call sites keep working. */
|
|
29
|
+
const className = $derived(cn(`${classAttr} ${legacyClass}`));
|
|
30
|
+
|
|
24
31
|
let contentElement = $state<HTMLElement | null>(null);
|
|
25
|
-
let parentItem = $state<HTMLElement | null>(null);
|
|
26
32
|
|
|
27
33
|
const context = getContext<NavigationMenuContextValue>(
|
|
28
34
|
NAVIGATION_MENU_CONTEXT_KEY,
|
|
@@ -36,42 +42,37 @@
|
|
|
36
42
|
: "",
|
|
37
43
|
);
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const trigger = parentItem?.querySelector('[data-navigation-menu-trigger]') as HTMLElement;
|
|
44
|
-
trigger?.focus();
|
|
45
|
-
}
|
|
46
|
-
}
|
|
45
|
+
/** Disclosure panel: one document listener while open, removed on close/unmount. */
|
|
46
|
+
$effect(() => {
|
|
47
|
+
if (!isActive || !contentElement) return;
|
|
48
|
+
const panel: HTMLElement = contentElement;
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
!contentElement.contains(event.target as Node) &&
|
|
52
|
-
parentItem &&
|
|
53
|
-
!parentItem.contains(event.target as Node)
|
|
54
|
-
) {
|
|
55
|
-
context?.setActiveItem(null);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
50
|
+
const parentItem = panel.closest<HTMLElement>(
|
|
51
|
+
"[data-navigation-menu-item]",
|
|
52
|
+
);
|
|
58
53
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
54
|
+
function handleClickOutside(event: MouseEvent) {
|
|
55
|
+
const target = event.target as Node;
|
|
56
|
+
if (!panel.contains(target) && !parentItem?.contains(target)) {
|
|
57
|
+
context?.setActiveItem(null);
|
|
58
|
+
}
|
|
65
59
|
}
|
|
66
|
-
});
|
|
67
60
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
61
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
62
|
+
if (event.key !== "Escape") return;
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
context?.setActiveItem(null);
|
|
65
|
+
parentItem
|
|
66
|
+
?.querySelector<HTMLElement>("[data-navigation-menu-trigger]")
|
|
67
|
+
?.focus();
|
|
74
68
|
}
|
|
69
|
+
|
|
70
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
71
|
+
panel.addEventListener("keydown", handleKeydown);
|
|
72
|
+
return () => {
|
|
73
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
74
|
+
panel.removeEventListener("keydown", handleKeydown);
|
|
75
|
+
};
|
|
75
76
|
});
|
|
76
77
|
</script>
|
|
77
78
|
|
|
@@ -79,11 +80,8 @@
|
|
|
79
80
|
<div
|
|
80
81
|
bind:this={contentElement}
|
|
81
82
|
id={panelId || undefined}
|
|
82
|
-
class="absolute left-0 top-full mt-2 bg-
|
|
83
|
-
role="menu"
|
|
84
|
-
onkeydown={handleKeydown}
|
|
83
|
+
class={cn("absolute left-0 top-full mt-2 bg-surface-overlay rounded-control shadow-lg border border-border p-4 z-dropdown min-w-[200px] transition-all duration-200 ease-in-out", className)}
|
|
85
84
|
data-navigation-menu-content
|
|
86
|
-
tabindex="-1"
|
|
87
85
|
{...restProps}
|
|
88
86
|
>
|
|
89
87
|
{@render children?.()}
|