@valentinkolb/cloud 0.1.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.
Files changed (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,16 @@
1
+ type LoginBtnProps = {
2
+ redirectTo?: string;
3
+ class?: string;
4
+ };
5
+
6
+ /** Link styled as a button that navigates to the login page. */
7
+ export default function LoginBtn(props: LoginBtnProps) {
8
+ const href = props.redirectTo ? `/auth/login?redirectTo=${encodeURIComponent(props.redirectTo)}` : "/auth/login";
9
+
10
+ return (
11
+ <a href={href} class={props.class ?? "btn-primary"}>
12
+ <i class="ti ti-login" />
13
+ <span>Sign In</span>
14
+ </a>
15
+ );
16
+ }
@@ -0,0 +1,58 @@
1
+ import type { BaseUser } from "../../contracts/shared";
2
+
3
+ type UserViewProps = {
4
+ user: BaseUser;
5
+ showRealm?: boolean;
6
+ };
7
+
8
+ const badgeStyles: Record<`${"ipa" | "local"}:${"user" | "guest"}`, { bg: string; text: string; label: string }> = {
9
+ "ipa:user": {
10
+ bg: "bg-green-100 dark:bg-green-900/30",
11
+ text: "text-green-700 dark:text-green-400",
12
+ label: "IPA",
13
+ },
14
+ "ipa:guest": {
15
+ bg: "bg-yellow-100 dark:bg-yellow-900/30",
16
+ text: "text-yellow-700 dark:text-yellow-400",
17
+ label: "IPA Guest",
18
+ },
19
+ "local:user": {
20
+ bg: "bg-sky-100 dark:bg-sky-900/30",
21
+ text: "text-sky-700 dark:text-sky-400",
22
+ label: "Local",
23
+ },
24
+ "local:guest": {
25
+ bg: "bg-zinc-100 dark:bg-zinc-800",
26
+ text: "text-zinc-600 dark:text-zinc-400",
27
+ label: "Guest",
28
+ },
29
+ };
30
+
31
+ export default function UserView(props: UserViewProps) {
32
+ const badge = () => badgeStyles[`${props.user.provider}:${props.user.profile}`] ?? badgeStyles["local:guest"];
33
+
34
+ return (
35
+ <div class="flex items-start gap-3 min-w-0">
36
+ <div class="flex shrink-0 items-center justify-center rounded-full bg-zinc-200 dark:bg-zinc-700 font-semibold text-zinc-600 dark:text-zinc-300 h-9 w-9 text-xs">
37
+ {props.user.uid.slice(0, 2).toUpperCase()}
38
+ </div>
39
+ <div class="flex flex-col gap-0.5 min-w-0">
40
+ <div class="flex items-center gap-2">
41
+ <span class="text-sm font-medium text-primary truncate">{props.user.displayName}</span>
42
+ {props.showRealm && badge() !== undefined && (
43
+ <span class={`tag ${badge()?.bg} ${badge()?.text}`}>{badge()?.label}</span>
44
+ )}
45
+ </div>
46
+ <div class="flex items-center gap-2 text-xs text-dimmed">
47
+ <span class="font-mono">{props.user.profile === "guest" ? `${props.user.uid.slice(0, 12)}...` : props.user.uid}</span>
48
+ {props.user.mail && (
49
+ <>
50
+ <span class="text-zinc-300 dark:text-zinc-600">|</span>
51
+ <span class="truncate">{props.user.mail}</span>
52
+ </>
53
+ )}
54
+ </div>
55
+ </div>
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,4 @@
1
+ export { default as Avatar } from "./Avatar";
2
+ export { default as UserView } from "./UserView";
3
+ export { default as GroupView } from "./GroupView";
4
+ export { default as LoginBtn } from "./LoginBtn";
@@ -0,0 +1,211 @@
1
+ import type { ParentProps, JSX } from "solid-js";
2
+ import { Show, children as resolveChildren, createMemo, createSignal, onCleanup, onMount } from "solid-js";
3
+ import { Portal } from "solid-js/web";
4
+ import type { DropdownItem } from "./Dropdown";
5
+
6
+ const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
7
+
8
+ type ContextMenuAction = Extract<DropdownItem, { label: string }>;
9
+ type ContextMenuSection = Extract<DropdownItem, { items: unknown }>;
10
+ type ContextMenuElement = Extract<DropdownItem, { element: unknown }>;
11
+
12
+ export type ContextMenuProps = ParentProps<{
13
+ children: JSX.Element;
14
+ elements: DropdownItem[];
15
+ class?: string | ((isOpen: boolean) => string);
16
+ disabled?: boolean;
17
+ onClose?: () => void;
18
+ onOpen?: () => void;
19
+ id?: string;
20
+ }>;
21
+
22
+ const ITEM_BASE_CLASSES =
23
+ "flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-white/30 dark:hover:bg-white/10";
24
+
25
+ const getVariantClasses = (variant?: "danger") =>
26
+ variant === "danger" ? "text-red-600 dark:text-red-400" : "text-zinc-700 dark:text-zinc-300";
27
+
28
+ const isSection = (item: DropdownItem): item is ContextMenuSection => "items" in item;
29
+ const isElement = (item: DropdownItem): item is ContextMenuElement => "element" in item;
30
+
31
+ const getMenuItems = (menu: HTMLDivElement | undefined) =>
32
+ menu ? Array.from(menu.querySelectorAll<HTMLElement>("[role='menuitem']")) : [];
33
+
34
+ export default function ContextMenu(props: ContextMenuProps) {
35
+ const id = props.id ?? `ctx-${crypto.randomUUID()}`;
36
+ const [coords, setCoords] = createSignal({ x: 0, y: 0 });
37
+ let menuRef: HTMLDivElement | undefined;
38
+ let hostRef: HTMLDivElement | undefined;
39
+
40
+ const isOpen = () => openMenuId() === id;
41
+ const hostClass = createMemo(() => (typeof props.class === "function" ? props.class(isOpen()) : props.class));
42
+ const content = resolveChildren(() => props.children);
43
+
44
+ const close = () => {
45
+ if (openMenuId() !== id) return;
46
+ setOpenMenuId(null);
47
+ props.onClose?.();
48
+ };
49
+
50
+ const focusItem = (index: number) => {
51
+ const items = getMenuItems(menuRef);
52
+ if (items.length === 0) return;
53
+ const next = Math.max(0, Math.min(index, items.length - 1));
54
+ items[next]?.focus();
55
+ };
56
+
57
+ const open = (event: MouseEvent) => {
58
+ if (props.disabled) return;
59
+ event.preventDefault();
60
+ event.stopPropagation();
61
+ setCoords({ x: event.clientX, y: event.clientY });
62
+ if (openMenuId() !== id) {
63
+ setOpenMenuId(id);
64
+ props.onOpen?.();
65
+ }
66
+ queueMicrotask(() => focusItem(0));
67
+ };
68
+
69
+ const handleKeyDown = (event: KeyboardEvent) => {
70
+ if (!isOpen()) return;
71
+ const items = getMenuItems(menuRef);
72
+ const currentIndex = items.findIndex((item) => item === document.activeElement);
73
+ switch (event.key) {
74
+ case "Escape":
75
+ event.preventDefault();
76
+ close();
77
+ hostRef?.focus();
78
+ break;
79
+ case "ArrowDown":
80
+ event.preventDefault();
81
+ focusItem(currentIndex + 1);
82
+ break;
83
+ case "ArrowUp":
84
+ event.preventDefault();
85
+ focusItem(currentIndex <= 0 ? items.length - 1 : currentIndex - 1);
86
+ break;
87
+ case "Home":
88
+ event.preventDefault();
89
+ focusItem(0);
90
+ break;
91
+ case "End":
92
+ event.preventDefault();
93
+ focusItem(items.length - 1);
94
+ break;
95
+ case "Tab":
96
+ close();
97
+ break;
98
+ }
99
+ };
100
+
101
+ onMount(() => {
102
+ const handlePointer = (event: MouseEvent) => {
103
+ if (!isOpen()) return;
104
+ const target = event.target;
105
+ if (target instanceof Node && (menuRef?.contains(target) || hostRef?.contains(target))) return;
106
+ close();
107
+ };
108
+
109
+ document.addEventListener("mousedown", handlePointer);
110
+ document.addEventListener("contextmenu", handlePointer);
111
+ document.addEventListener("keydown", handleKeyDown);
112
+ onCleanup(() => {
113
+ document.removeEventListener("mousedown", handlePointer);
114
+ document.removeEventListener("contextmenu", handlePointer);
115
+ document.removeEventListener("keydown", handleKeyDown);
116
+ });
117
+ });
118
+
119
+ const renderAction = (item: ContextMenuAction) => {
120
+ const classes = `${ITEM_BASE_CLASSES} ${getVariantClasses(item.variant)}`;
121
+ const content = (
122
+ <>
123
+ {item.icon && <i class={item.icon} />}
124
+ <span>{item.label}</span>
125
+ </>
126
+ );
127
+
128
+ if ("href" in item && item.href) {
129
+ return (
130
+ <a
131
+ href={item.href}
132
+ target={item.external ? "_blank" : undefined}
133
+ rel={item.external ? "noopener noreferrer" : undefined}
134
+ role="menuitem"
135
+ tabIndex={-1}
136
+ class={classes}
137
+ onClick={close}
138
+ >
139
+ {content}
140
+ </a>
141
+ );
142
+ }
143
+
144
+ return (
145
+ <button
146
+ type="button"
147
+ role="menuitem"
148
+ tabIndex={-1}
149
+ class={classes}
150
+ onClick={(event) => {
151
+ event.preventDefault();
152
+ event.stopPropagation();
153
+ if ("action" in item && item.action) {
154
+ item.action();
155
+ }
156
+ close();
157
+ }}
158
+ >
159
+ {content}
160
+ </button>
161
+ );
162
+ };
163
+
164
+ return (
165
+ <>
166
+ <div
167
+ ref={hostRef}
168
+ role="group"
169
+ class={hostClass()}
170
+ onContextMenu={open}
171
+ >
172
+ {content()}
173
+ </div>
174
+
175
+ <Show when={isOpen()}>
176
+ <Portal>
177
+ <div
178
+ ref={menuRef}
179
+ role="menu"
180
+ aria-label="Context menu"
181
+ class="fixed z-50 w-52 max-w-[min(22rem,calc(100vw-1rem))] overflow-y-auto rounded-xl border border-zinc-300/60 bg-white/95 p-0 text-zinc-900 shadow-lg ring-1 ring-black/5 backdrop-blur-sm dark:border-zinc-600/50 dark:bg-zinc-950/95 dark:text-zinc-100"
182
+ style={{
183
+ left: `${Math.min(coords().x, window.innerWidth - 220)}px`,
184
+ top: `${Math.min(coords().y, window.innerHeight - 320)}px`,
185
+ }}
186
+ >
187
+ {props.elements.map((item, index) =>
188
+ isSection(item) ? (
189
+ <>
190
+ {index > 0 && <hr class="border-white/20 dark:border-zinc-700/25" />}
191
+ <Show when={item.sectionLabel}>
192
+ <div class="px-4 pt-3 pb-1 text-xs font-medium uppercase tracking-wider text-zinc-500">{item.sectionLabel}</div>
193
+ </Show>
194
+ {item.items.map((sectionItem) => (isElement(sectionItem) ? (typeof sectionItem.element === "function" ? sectionItem.element(close) : sectionItem.element) : renderAction(sectionItem)))}
195
+ </>
196
+ ) : isElement(item) ? (
197
+ typeof item.element === "function" ? (
198
+ item.element(close)
199
+ ) : (
200
+ item.element
201
+ )
202
+ ) : (
203
+ renderAction(item)
204
+ ),
205
+ )}
206
+ </div>
207
+ </Portal>
208
+ </Show>
209
+ </>
210
+ );
211
+ }
@@ -0,0 +1,28 @@
1
+ import { createSignal } from "solid-js";
2
+ import { copyToClipboard } from "@valentinkolb/stdlib/browser";
3
+
4
+ type CopyButtonProps = {
5
+ /** Text to copy to clipboard */
6
+ text: string;
7
+ /** Optional label - if omitted, renders icon-only */
8
+ label?: string;
9
+ /** Additional CSS classes */
10
+ class?: string;
11
+ };
12
+
13
+ export default function CopyButton(props: CopyButtonProps) {
14
+ const [copied, setCopied] = createSignal(false);
15
+
16
+ const handleCopy = async () => {
17
+ await copyToClipboard(props.text);
18
+ setCopied(true);
19
+ setTimeout(() => setCopied(false), 2000);
20
+ };
21
+
22
+ return (
23
+ <button type="button" class={props.class ?? "btn-simple text-[10px] px-1.5 py-0.5"} onClick={handleCopy}>
24
+ <i class={copied() ? "ti ti-check" : "ti ti-copy"} />
25
+ {props.label !== undefined && <span>{copied() ? "Copied" : props.label}</span>}
26
+ </button>
27
+ );
28
+ }
@@ -0,0 +1,194 @@
1
+ import type { JSX } from "solid-js";
2
+
3
+ // ==========================
4
+ // Types
5
+ // ==========================
6
+
7
+ type DropdownActionBase = {
8
+ icon?: string;
9
+ label: string;
10
+ variant?: "danger";
11
+ };
12
+
13
+ type DropdownActionClick = DropdownActionBase & {
14
+ action: () => void;
15
+ href?: never;
16
+ };
17
+
18
+ type DropdownActionLink = DropdownActionBase & {
19
+ href: string;
20
+ external?: boolean;
21
+ action?: never;
22
+ };
23
+
24
+ type DropdownAction = DropdownActionClick | DropdownActionLink;
25
+
26
+ type DropdownElement = {
27
+ element: JSX.Element | ((close: () => void) => JSX.Element);
28
+ };
29
+
30
+ type DropdownSection = {
31
+ sectionLabel?: string;
32
+ items: Array<DropdownAction | DropdownElement>;
33
+ };
34
+
35
+ export type DropdownItem = DropdownAction | DropdownElement | DropdownSection;
36
+
37
+ type DropdownProps = {
38
+ trigger: JSX.Element;
39
+ elements: DropdownItem[];
40
+ position?: "bottom-right" | "bottom-left" | "top-right" | "top-left" | (() => "bottom-right" | "bottom-left" | "top-right" | "top-left");
41
+ width?: string;
42
+ className?: string;
43
+ /** Called when the dropdown closes (click outside, escape, or programmatic) */
44
+ onClose?: () => void;
45
+ };
46
+
47
+ // ==========================
48
+ // Constants
49
+ // ==========================
50
+
51
+ const POSITION_STYLES: Record<string, string> = {
52
+ "bottom-right":
53
+ "top: anchor(bottom); left: anchor(left); margin-top: 4px;" +
54
+ "position-try-fallbacks: --flip-block;" +
55
+ "position-try: --flip-block { bottom: anchor(top); top: auto; margin-top: 0; margin-bottom: 4px; };",
56
+ "bottom-left":
57
+ "top: anchor(bottom); right: anchor(right); margin-top: 4px;" +
58
+ "position-try-fallbacks: --flip-block-left;" +
59
+ "position-try: --flip-block-left { bottom: anchor(top); top: auto; margin-top: 0; margin-bottom: 4px; };",
60
+ "top-right":
61
+ "bottom: anchor(top); left: anchor(left); margin-bottom: 4px;" +
62
+ "position-try-fallbacks: --flip-block-down;" +
63
+ "position-try: --flip-block-down { top: anchor(bottom); bottom: auto; margin-bottom: 0; margin-top: 4px; };",
64
+ "top-left":
65
+ "bottom: anchor(top); right: anchor(right); margin-bottom: 4px;" +
66
+ "position-try-fallbacks: --flip-block-down-left;" +
67
+ "position-try: --flip-block-down-left { top: anchor(bottom); bottom: auto; margin-bottom: 0; margin-top: 4px; };",
68
+ };
69
+
70
+ const ITEM_BASE_CLASSES = "flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-white/30 dark:hover:bg-white/10";
71
+
72
+ // ==========================
73
+ // Component
74
+ // ==========================
75
+
76
+ /** Accessible dropdown menu with popover light-dismiss and CSS anchor positioning. */
77
+ export default function Dropdown(props: DropdownProps) {
78
+ const width = props.width ?? "w-48";
79
+ const anchor = `--dd-${crypto.randomUUID()}`;
80
+ let triggerRef!: HTMLButtonElement;
81
+ let popoverRef!: HTMLDivElement;
82
+ let isOpen = false;
83
+
84
+ const close = (): void => popoverRef?.hidePopover();
85
+
86
+ const getPositionStyle = (): string => {
87
+ const pos = typeof props.position === "function" ? props.position() : (props.position ?? "bottom-right");
88
+ return POSITION_STYLES[pos] ?? POSITION_STYLES["bottom-right"]!;
89
+ };
90
+
91
+ const getVariantClasses = (variant?: "danger"): string =>
92
+ variant === "danger" ? "text-red-600 dark:text-red-400" : "text-zinc-700 dark:text-zinc-300";
93
+
94
+ const renderItem = (item: DropdownAction | DropdownElement): JSX.Element => {
95
+ if ("element" in item) {
96
+ return typeof item.element === "function" ? item.element(close) : item.element;
97
+ }
98
+
99
+ const classes = `${ITEM_BASE_CLASSES} ${getVariantClasses(item.variant)}`;
100
+ const content = (
101
+ <>
102
+ {item.icon && <i class={item.icon} />}
103
+ <span>{item.label}</span>
104
+ </>
105
+ );
106
+
107
+ // Link variant
108
+ if ("href" in item && item.href) {
109
+ return (
110
+ <a
111
+ href={item.href}
112
+ target={item.external ? "_blank" : undefined}
113
+ rel={item.external ? "noopener noreferrer" : undefined}
114
+ class={classes}
115
+ onClick={close}
116
+ >
117
+ {content}
118
+ </a>
119
+ );
120
+ }
121
+
122
+ // Button variant
123
+ return (
124
+ <button
125
+ type="button"
126
+ class={classes}
127
+ onClick={(e) => {
128
+ e.stopPropagation();
129
+ e.preventDefault();
130
+ (item as DropdownActionClick).action();
131
+ close();
132
+ }}
133
+ >
134
+ {content}
135
+ </button>
136
+ );
137
+ };
138
+
139
+ return (
140
+ <>
141
+ <button
142
+ type="button"
143
+ class="inline-flex"
144
+ ref={triggerRef}
145
+ style={`anchor-name: ${anchor}`}
146
+ onClick={(e) => {
147
+ e.stopPropagation();
148
+ if (isOpen) {
149
+ popoverRef.hidePopover();
150
+ } else {
151
+ const base = `position-anchor: ${anchor}; position: fixed; inset: unset; margin: 0; scrollbar-gutter: auto;`;
152
+ popoverRef.setAttribute("style", props.className ? base : `${base} ${getPositionStyle()}`);
153
+ popoverRef.showPopover();
154
+ }
155
+ }}
156
+ >
157
+ {props.trigger}
158
+ </button>
159
+
160
+ <div
161
+ ref={(el) => {
162
+ popoverRef = el;
163
+ el.addEventListener("toggle", (e) => {
164
+ const newState = (e as ToggleEvent).newState;
165
+ const wasOpen = isOpen;
166
+ isOpen = newState === "open";
167
+ // Call onClose when transitioning from open to closed
168
+ if (wasOpen && !isOpen && props.onClose) {
169
+ props.onClose();
170
+ }
171
+ });
172
+ }}
173
+ popover="auto"
174
+ role="menu"
175
+ aria-label="Dropdown menu"
176
+ class={`${width} overflow-y-auto max-h-[min(24rem,80dvh)] paper p-0 border! border-zinc-300/60! dark:border-zinc-600/50! ${props.className ?? ""}`}
177
+ >
178
+ {props.elements.map((item, i) =>
179
+ "items" in item ? (
180
+ <>
181
+ {i > 0 && <hr class="border-white/20 dark:border-zinc-700/25" />}
182
+ {item.sectionLabel && (
183
+ <div class="px-4 pt-3 pb-1 text-xs uppercase tracking-wider font-medium text-zinc-500">{item.sectionLabel}</div>
184
+ )}
185
+ {item.items.map(renderItem)}
186
+ </>
187
+ ) : (
188
+ renderItem(item)
189
+ ),
190
+ )}
191
+ </div>
192
+ </>
193
+ );
194
+ }