@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.
- package/package.json +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- 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
|
+
}
|