@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,169 @@
1
+ import { For, onMount } from "solid-js";
2
+ import { InputWrapper, createInputA11y } from "./util";
3
+
4
+ type PinInputProps = {
5
+ label?: string;
6
+ description?: string;
7
+ length?: number;
8
+ value?: () => string;
9
+ onChange?: (value: string) => void;
10
+ error?: () => string | undefined;
11
+ disabled?: boolean;
12
+ stretch?: boolean;
13
+ required?: boolean;
14
+ };
15
+
16
+ /**
17
+ * PIN input component with individual digit fields
18
+ * @param label - Optional label text
19
+ * @param description - Optional description text
20
+ * @param length - Number of PIN digits (default: 6)
21
+ * @param value - Reactive string value getter
22
+ * @param onChange - Called when PIN value changes
23
+ * @param error - Reactive error message getter
24
+ * @param disabled - Disable all input fields
25
+ * @param stretch - Make input fields stretch to full width
26
+ * @param required - Show required asterisk after label
27
+ */
28
+ const PinInput = ({
29
+ label,
30
+ description,
31
+ length = 6,
32
+ value,
33
+ onChange,
34
+ error,
35
+ disabled = false,
36
+ stretch = false,
37
+ required = false,
38
+ }: PinInputProps) => {
39
+ let inputRefs: HTMLInputElement[] = [];
40
+ const a11y = createInputA11y({ description, error });
41
+
42
+ const handleChange = (index: number, newValue: string) => {
43
+ if (disabled) return;
44
+
45
+ // Only allow single digits
46
+ const digit = newValue.slice(-1).replace(/[^0-9]/g, "");
47
+
48
+ const currentValue = value?.() || "";
49
+ const before = currentValue.slice(0, index);
50
+ const after = currentValue.slice(index + 1);
51
+
52
+ onChange?.(before + digit + after);
53
+
54
+ // Auto-focus next field
55
+ if (digit && index < length - 1) {
56
+ inputRefs[index + 1]?.focus();
57
+ inputRefs[index + 1]?.select();
58
+ }
59
+ };
60
+
61
+ const handleKeyDown = (index: number, e: KeyboardEvent) => {
62
+ if (disabled) return;
63
+
64
+ switch (e.key) {
65
+ case "Backspace":
66
+ const val = value?.() || "";
67
+ if (!val[index] && index > 0) {
68
+ e.preventDefault();
69
+ // Move to previous field and delete its content
70
+ const currentValue = val;
71
+ const before = currentValue.slice(0, index - 1);
72
+ const after = currentValue.slice(index);
73
+ onChange?.(before + after);
74
+ inputRefs[index - 1]?.focus();
75
+ }
76
+ break;
77
+
78
+ case "ArrowLeft":
79
+ e.preventDefault();
80
+ if (index > 0) {
81
+ inputRefs[index - 1]?.focus();
82
+ inputRefs[index - 1]?.select();
83
+ }
84
+ break;
85
+
86
+ case "ArrowRight":
87
+ e.preventDefault();
88
+ if (index < length - 1) {
89
+ inputRefs[index + 1]?.focus();
90
+ inputRefs[index + 1]?.select();
91
+ }
92
+ break;
93
+ }
94
+ };
95
+
96
+ const handlePaste = (e: ClipboardEvent) => {
97
+ e.preventDefault();
98
+ if (disabled) return;
99
+
100
+ const pastedData = e.clipboardData?.getData("text") || "";
101
+ const pastedDigits = pastedData.replace(/[^0-9]/g, "");
102
+
103
+ if (pastedDigits.length > 0) {
104
+ const startIndex = inputRefs.findIndex((ref) => ref === document.activeElement);
105
+ const index = startIndex >= 0 ? startIndex : 0;
106
+
107
+ const currentValue = value?.() || "";
108
+ const before = currentValue.slice(0, index);
109
+ const pasted = pastedDigits.slice(0, length - index);
110
+ const after = currentValue.slice(index + pasted.length);
111
+
112
+ onChange?.(before + pasted + after);
113
+
114
+ // Focus appropriate next field
115
+ const nextIndex = Math.min(index + pasted.length, length - 1);
116
+ inputRefs[nextIndex]?.focus();
117
+ inputRefs[nextIndex]?.select();
118
+ }
119
+ };
120
+
121
+ // Add paste listener on mount
122
+ onMount(() => {
123
+ const container = inputRefs[0]?.parentElement?.parentElement;
124
+ if (container) {
125
+ container.addEventListener("paste", handlePaste as any);
126
+ return () => container.removeEventListener("paste", handlePaste as any);
127
+ }
128
+ });
129
+
130
+ return (
131
+ <InputWrapper
132
+ label={label}
133
+ description={description}
134
+ error={error?.()}
135
+ required={required}
136
+ inputId={a11y.inputId}
137
+ descriptionId={a11y.descriptionId}
138
+ errorId={a11y.errorId}
139
+ >
140
+ <div class="flex gap-1 md:gap-2" role="group" aria-labelledby={a11y.inputId} aria-describedby={a11y.ariaDescribedBy()}>
141
+ <For each={new Array(length).fill(0)}>
142
+ {(_, index) => (
143
+ <input
144
+ ref={(el) => (inputRefs[index()] = el)}
145
+ type="text"
146
+ inputMode="numeric"
147
+ pattern="[0-9]"
148
+ maxLength={1}
149
+ class={`input ${stretch ? "w-full" : "w-10"} text-center font-mono font-semibold transition-all ${
150
+ (value?.() || "")[index()] ? "bg-zinc-50 dark:bg-zinc-800 " : ""
151
+ } ${disabled ? "cursor-not-allowed opacity-50" : ""} ${error?.() ? "!border-red-500" : ""}`}
152
+ value={(value?.() || "")[index()] || ""}
153
+ onInput={(e) => handleChange(index(), e.currentTarget.value)}
154
+ onKeyDown={(e) => handleKeyDown(index(), e)}
155
+ onFocus={(e) => e.currentTarget.select()}
156
+ disabled={disabled}
157
+ aria-label={`PIN digit ${index() + 1} of ${length}`}
158
+ aria-invalid={!!error?.()}
159
+ aria-required={index() === 0 ? required : undefined}
160
+ autocomplete="off"
161
+ />
162
+ )}
163
+ </For>
164
+ </div>
165
+ </InputWrapper>
166
+ );
167
+ };
168
+
169
+ export default PinInput;
@@ -0,0 +1,99 @@
1
+ import { For } from "solid-js";
2
+
3
+ export type SegmentOption<T extends string> = {
4
+ value: T;
5
+ label: string;
6
+ icon?: string;
7
+ };
8
+
9
+ type SegmentedControlProps<T extends string> = {
10
+ options: SegmentOption<T>[];
11
+ value: () => T;
12
+ onChange: (value: T) => void;
13
+ disabled?: boolean;
14
+ ariaLabel?: string;
15
+ };
16
+
17
+ /**
18
+ * Segmented control for switching between options.
19
+ * Similar to iOS segmented control or radio button group.
20
+ */
21
+ function SegmentedControl<T extends string>({
22
+ options,
23
+ value,
24
+ onChange,
25
+ disabled = false,
26
+ ariaLabel = "Options",
27
+ }: SegmentedControlProps<T>) {
28
+ const selectRelative = (currentIndex: number, direction: -1 | 1) => {
29
+ const nextIndex = (currentIndex + direction + options.length) % options.length;
30
+ const next = options[nextIndex];
31
+ if (next) onChange(next.value);
32
+ };
33
+
34
+ const onSegmentKeyDown = (event: KeyboardEvent, currentIndex: number) => {
35
+ if (disabled) return;
36
+
37
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
38
+ event.preventDefault();
39
+ selectRelative(currentIndex, 1);
40
+ return;
41
+ }
42
+
43
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
44
+ event.preventDefault();
45
+ selectRelative(currentIndex, -1);
46
+ return;
47
+ }
48
+
49
+ if (event.key === "Home") {
50
+ event.preventDefault();
51
+ const first = options[0];
52
+ if (first) onChange(first.value);
53
+ return;
54
+ }
55
+
56
+ if (event.key === "End") {
57
+ event.preventDefault();
58
+ const last = options[options.length - 1];
59
+ if (last) onChange(last.value);
60
+ }
61
+ };
62
+
63
+ return (
64
+ <div
65
+ role="radiogroup"
66
+ aria-label={ariaLabel}
67
+ class="inline-flex w-full items-stretch rounded-xl border border-zinc-300/50 bg-zinc-100/55 p-0.5 dark:border-zinc-700/50 dark:bg-zinc-900/50"
68
+ classList={{ "opacity-50 pointer-events-none": disabled }}
69
+ >
70
+ <For each={options}>
71
+ {(option, index) => (
72
+ <button
73
+ type="button"
74
+ role="radio"
75
+ aria-checked={value() === option.value}
76
+ tabIndex={value() === option.value ? 0 : -1}
77
+ class="relative z-0 flex-1 min-w-0 rounded-lg px-2 py-1 text-xs leading-4 flex items-center justify-center gap-1 transition-[background-color,color,box-shadow] duration-150 outline-none"
78
+ classList={{
79
+ "z-10 rounded-[0.95rem] bg-zinc-200/80 dark:bg-zinc-800/95 text-zinc-900 dark:text-zinc-100":
80
+ value() === option.value,
81
+ "text-zinc-700 dark:text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300 hover:bg-zinc-50/65 dark:hover:bg-zinc-800/35":
82
+ value() !== option.value,
83
+ "after:absolute after:right-0 after:top-1 after:bottom-1 after:w-px after:bg-zinc-300/75 dark:after:bg-zinc-700/75":
84
+ index() < options.length - 1 && value() !== option.value && value() !== options[index() + 1]?.value,
85
+ }}
86
+ onClick={() => onChange(option.value)}
87
+ onKeyDown={(event) => onSegmentKeyDown(event, index())}
88
+ disabled={disabled}
89
+ >
90
+ {option.icon && <i class={option.icon} />}
91
+ {option.label}
92
+ </button>
93
+ )}
94
+ </For>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export default SegmentedControl;
@@ -0,0 +1,288 @@
1
+ import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
2
+ import { InputWrapper, createInputA11y } from "./util";
3
+
4
+ type SelectOption =
5
+ | string
6
+ | {
7
+ id: string;
8
+ label?: string;
9
+ description?: string;
10
+ icon?: string;
11
+ };
12
+
13
+ type SelectInputProps = {
14
+ label?: string;
15
+ description?: string;
16
+ placeholder?: string;
17
+ icon?: string;
18
+ activeIcon?: string;
19
+ value?: () => string | undefined;
20
+ onChange?: (value: string) => void;
21
+ error?: () => string | undefined;
22
+ options: SelectOption[];
23
+ required?: boolean;
24
+ clearable?: boolean;
25
+ disabled?: boolean;
26
+ };
27
+
28
+ const SelectInput = (props: SelectInputProps) => {
29
+ const placeholder = () => props.placeholder ?? "Select...";
30
+ const icon = () => props.icon ?? "ti ti-chevron-down";
31
+ const activeIcon = () => props.activeIcon ?? "ti ti-chevron-up";
32
+ const disabled = () => props.disabled ?? false;
33
+ const clearable = () => props.clearable ?? false;
34
+
35
+ const options = () =>
36
+ props.options.map((option) =>
37
+ typeof option === "object" ? { ...option, label: option.label || option.id } : { id: option, label: option },
38
+ );
39
+
40
+ const [isOpen, setIsOpen] = createSignal(false);
41
+ const [focusedIndex, setFocusedIndex] = createSignal(-1);
42
+ const [isDarkTheme, setIsDarkTheme] = createSignal(false);
43
+ const a11y = createInputA11y({ description: props.description, error: props.error });
44
+
45
+ let triggerRef: HTMLDivElement | undefined;
46
+ let dialogRef: HTMLDialogElement | undefined;
47
+ let optionRefs: HTMLDivElement[] = [];
48
+
49
+ const selectedOption = createMemo(() => options().find((option) => option.id === props.value?.()));
50
+
51
+ const syncTheme = () => {
52
+ if (typeof document === "undefined") return;
53
+ setIsDarkTheme(document.documentElement.classList.contains("dark") || document.body.classList.contains("dark"));
54
+ };
55
+
56
+ const focusOption = (index: number) => {
57
+ setFocusedIndex(index);
58
+ optionRefs[index]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
59
+ };
60
+
61
+ const navigateOptions = (direction: "next" | "previous") => {
62
+ const count = options().length;
63
+ if (!count) return;
64
+
65
+ let nextIndex = focusedIndex();
66
+ if (direction === "next") {
67
+ nextIndex = nextIndex < count - 1 ? nextIndex + 1 : 0;
68
+ } else {
69
+ nextIndex = nextIndex > 0 ? nextIndex - 1 : count - 1;
70
+ }
71
+
72
+ focusOption(nextIndex);
73
+ };
74
+
75
+ const toggleDropdown = (open: boolean) => {
76
+ if (disabled()) return;
77
+
78
+ syncTheme();
79
+ setIsOpen(open);
80
+ if (!open) {
81
+ dialogRef?.close();
82
+ setFocusedIndex(-1);
83
+ return;
84
+ }
85
+
86
+ const currentIndex = options().findIndex((option) => option.id === props.value?.());
87
+ setFocusedIndex(currentIndex >= 0 ? currentIndex : 0);
88
+
89
+ if (dialogRef && triggerRef) {
90
+ const rect = triggerRef.getBoundingClientRect();
91
+ const spaceBelow = window.innerHeight - rect.bottom;
92
+ const spaceAbove = rect.top;
93
+ const dropdownMaxHeight = 260; // max-h-60 = 15rem ~ 240px + padding
94
+
95
+ dialogRef.style.left = `${rect.left}px`;
96
+ dialogRef.style.width = `${rect.width}px`;
97
+
98
+ if (spaceBelow < dropdownMaxHeight && spaceAbove > spaceBelow) {
99
+ // Open above
100
+ dialogRef.style.top = "auto";
101
+ dialogRef.style.bottom = `${window.innerHeight - rect.top + 8}px`;
102
+ } else {
103
+ // Open below (default)
104
+ dialogRef.style.top = `${rect.bottom + 8}px`;
105
+ dialogRef.style.bottom = "auto";
106
+ }
107
+
108
+ dialogRef.showModal();
109
+ }
110
+ };
111
+
112
+ const selectOption = (option: { id: string; label: string }) => {
113
+ props.onChange?.(option.id);
114
+ toggleDropdown(false);
115
+ triggerRef?.focus();
116
+ };
117
+
118
+ const clearValue = (event: MouseEvent) => {
119
+ event.stopPropagation();
120
+ props.onChange?.("");
121
+ triggerRef?.focus();
122
+ };
123
+
124
+ const handleKeyDown = (event: KeyboardEvent) => {
125
+ const open = isOpen();
126
+
127
+ switch (event.key) {
128
+ case "ArrowDown":
129
+ event.preventDefault();
130
+ if (!open) {
131
+ toggleDropdown(true);
132
+ } else {
133
+ navigateOptions("next");
134
+ }
135
+ break;
136
+ case "ArrowUp":
137
+ event.preventDefault();
138
+ if (open) {
139
+ navigateOptions("previous");
140
+ }
141
+ break;
142
+ case "Enter":
143
+ case " ":
144
+ event.preventDefault();
145
+ if (open && focusedIndex() >= 0) {
146
+ const option = options()[focusedIndex()];
147
+ if (option) selectOption(option);
148
+ } else if (!open) {
149
+ toggleDropdown(true);
150
+ }
151
+ break;
152
+ case "Escape":
153
+ if (open) {
154
+ event.preventDefault();
155
+ toggleDropdown(false);
156
+ }
157
+ break;
158
+ case "Tab":
159
+ if (open) {
160
+ toggleDropdown(false);
161
+ }
162
+ break;
163
+ }
164
+ };
165
+
166
+ const handleDialogClick = (event: MouseEvent) => {
167
+ if (event.target === dialogRef) {
168
+ toggleDropdown(false);
169
+ }
170
+ };
171
+
172
+ onCleanup(() => dialogRef?.close());
173
+
174
+ return (
175
+ <InputWrapper
176
+ label={props.label}
177
+ description={props.description}
178
+ error={props.error?.()}
179
+ required={props.required}
180
+ inputId={a11y.inputId}
181
+ descriptionId={a11y.descriptionId}
182
+ errorId={a11y.errorId}
183
+ >
184
+ <div class="relative">
185
+ <div class="group relative flex-1">
186
+ <div class="pointer-events-none absolute inset-y-0 left-2 z-10 flex items-center text-zinc-500">
187
+ <i class={`${selectedOption()?.icon || (isOpen() ? activeIcon() : icon())} ${isOpen() ? "text-blue-500" : ""}`} />
188
+ </div>
189
+
190
+ <div
191
+ ref={triggerRef}
192
+ id={a11y.inputId}
193
+ class={`input w-full pl-9 pr-8 ${
194
+ isOpen() ? "!border-blue-500 !bg-white dark:!border-blue-400 dark:!bg-zinc-900" : ""
195
+ } ${disabled() ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
196
+ onClick={() => toggleDropdown(!isOpen())}
197
+ onKeyDown={handleKeyDown}
198
+ tabIndex={disabled() ? -1 : 0}
199
+ role="combobox"
200
+ aria-expanded={isOpen()}
201
+ aria-haspopup="listbox"
202
+ aria-label={!props.label ? "Select an option" : undefined}
203
+ aria-describedby={a11y.ariaDescribedBy()}
204
+ aria-invalid={!!props.error?.()}
205
+ aria-required={props.required}
206
+ aria-disabled={disabled()}
207
+ >
208
+ <Show when={selectedOption()} fallback={<span class="text-zinc-400 dark:text-zinc-500">{placeholder()}</span>}>
209
+ <span class="text-zinc-700 dark:text-zinc-300">{selectedOption()!.label}</span>
210
+ </Show>
211
+ </div>
212
+
213
+ <Show when={clearable() && selectedOption() && !disabled()}>
214
+ <button
215
+ type="button"
216
+ class="absolute inset-y-0 right-2 flex items-center px-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
217
+ onClick={clearValue}
218
+ tabIndex={-1}
219
+ aria-label="Clear selection"
220
+ >
221
+ <i class="ti ti-x text-sm" />
222
+ </button>
223
+ </Show>
224
+ </div>
225
+
226
+ <dialog
227
+ ref={dialogRef}
228
+ class="popup border border-zinc-200 p-1 backdrop:bg-transparent dark:border-zinc-700"
229
+ classList={{ dark: isDarkTheme() }}
230
+ onKeyDown={handleKeyDown}
231
+ onClick={handleDialogClick}
232
+ aria-label="Options"
233
+ >
234
+ <div class="flex max-h-60 flex-col gap-1 overflow-y-auto" role="listbox" aria-label={props.label || "Options"}>
235
+ <For each={options()} fallback={<div class="px-3 py-2 text-sm text-zinc-500 dark:text-zinc-400">No options available</div>}>
236
+ {(option, index) => {
237
+ const isSelected = () => option.id === props.value?.();
238
+ const isFocused = () => index() === focusedIndex();
239
+
240
+ return (
241
+ <div
242
+ ref={(el) => (optionRefs[index()] = el)}
243
+ class="group flex cursor-pointer select-none items-center px-3 py-2 text-sm transition-all"
244
+ onClick={() => selectOption(option)}
245
+ onKeyDown={(event) => {
246
+ if (event.key === "Enter" || event.key === " ") {
247
+ event.preventDefault();
248
+ selectOption(option);
249
+ }
250
+ }}
251
+ onMouseEnter={() => setFocusedIndex(index())}
252
+ role="option"
253
+ aria-label={option.label}
254
+ aria-selected={isSelected()}
255
+ tabIndex={-1}
256
+ >
257
+ <Show when={option.icon}>
258
+ <i class={`${option.icon} mr-3 text-zinc-500`} />
259
+ </Show>
260
+
261
+ <div class="min-w-0 flex-1">
262
+ <span
263
+ class={`truncate text-zinc-700 dark:text-zinc-300 ${
264
+ isFocused()
265
+ ? "text-primary underline underline-offset-2"
266
+ : "group-hover:underline group-hover:underline-offset-2"
267
+ }`}
268
+ >
269
+ {option.label}
270
+ </span>
271
+ <Show when={option.description}>
272
+ <div class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">{option.description}</div>
273
+ </Show>
274
+ </div>
275
+ </div>
276
+ );
277
+ }}
278
+ </For>
279
+ </div>
280
+ </dialog>
281
+ </div>
282
+ </InputWrapper>
283
+ );
284
+ };
285
+
286
+ export { SelectInput };
287
+ export const Select = SelectInput;
288
+ export default SelectInput;
@@ -0,0 +1,61 @@
1
+ import { For, Show } from "solid-js";
2
+ import Dropdown from "../misc/Dropdown";
3
+ import type { DropdownItem } from "../misc/Dropdown";
4
+
5
+ export type SelectChipOption<T extends string | number = string> = {
6
+ value: T;
7
+ label: string;
8
+ };
9
+
10
+ type SelectChipProps<T extends string | number = string> = {
11
+ /** Current value */
12
+ value: T;
13
+ /** Options list */
14
+ options: SelectChipOption<T>[];
15
+ /** Change handler */
16
+ onChange: (value: T) => void;
17
+ /** Optional icon */
18
+ icon?: string;
19
+ /** Dropdown position */
20
+ position?: "bottom-left" | "bottom-right";
21
+ };
22
+
23
+ /**
24
+ * Minimal single-select chip using Dropdown.
25
+ * Displays current selection inline, opens dropdown on click.
26
+ */
27
+ export default function SelectChip<T extends string | number = string>(props: SelectChipProps<T>) {
28
+ const selectedLabel = () => props.options.find((o) => o.value === props.value)?.label ?? "";
29
+
30
+ const dropdownElements = (): DropdownItem[] =>
31
+ props.options.map((option) => ({
32
+ element: (
33
+ <button
34
+ type="button"
35
+ class="flex w-full items-center justify-between gap-3 px-3 py-1.5 text-sm text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
36
+ onClick={(e) => {
37
+ e.preventDefault();
38
+ e.stopPropagation();
39
+ props.onChange(option.value);
40
+ }}
41
+ >
42
+ <span class="truncate">{option.label}</span>
43
+ <Show when={option.value === props.value}>
44
+ <i class="ti ti-check text-blue-500 text-xs" />
45
+ </Show>
46
+ </button>
47
+ ),
48
+ }));
49
+
50
+ const trigger = (
51
+ <div class="btn-input btn-input-sm">
52
+ <Show when={props.icon}>
53
+ <i class={`${props.icon} text-zinc-500 dark:text-zinc-400`} />
54
+ </Show>
55
+ <span class="truncate">{selectedLabel()}</span>
56
+ <i class="ti ti-chevron-down text-zinc-500 dark:text-zinc-400 text-[10px]" />
57
+ </div>
58
+ );
59
+
60
+ return <Dropdown trigger={trigger} elements={dropdownElements()} position={props.position ?? "bottom-right"} width="w-40" />;
61
+ }