@valentinkolb/cloud 0.4.0 → 0.5.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 +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { mutation, timed } from "@valentinkolb/stdlib/solid";
|
|
2
|
+
import type { JSX } from "solid-js";
|
|
3
|
+
import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
|
4
|
+
import { createInputA11y, InputWrapper } from "./util";
|
|
5
|
+
|
|
6
|
+
export type MultiSelectOption =
|
|
7
|
+
| string
|
|
8
|
+
| {
|
|
9
|
+
id: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
icon?: string;
|
|
13
|
+
color?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type NormalizedOption = {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
icon?: string;
|
|
21
|
+
color?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type MultiSelectFetchDataFn = (query: string, signal: AbortSignal) => Promise<MultiSelectOption[]>;
|
|
25
|
+
|
|
26
|
+
export type MultiSelectInputProps = {
|
|
27
|
+
label?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
placeholder?: string;
|
|
30
|
+
icon?: string;
|
|
31
|
+
activeIcon?: string;
|
|
32
|
+
value?: () => string[];
|
|
33
|
+
onChange?: (value: string[]) => void;
|
|
34
|
+
error?: () => string | undefined;
|
|
35
|
+
options?: MultiSelectOption[];
|
|
36
|
+
fetchData?: MultiSelectFetchDataFn;
|
|
37
|
+
selectedOptions?: () => MultiSelectOption[];
|
|
38
|
+
fetchDebounceMs?: number;
|
|
39
|
+
required?: boolean;
|
|
40
|
+
clearable?: boolean;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
45
|
+
|
|
46
|
+
const expandHex = (color: string): string => {
|
|
47
|
+
if (color.length !== 4) return color;
|
|
48
|
+
return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const hexToRgb = (color: string): { r: number; g: number; b: number } | null => {
|
|
52
|
+
if (!HEX_COLOR.test(color)) return null;
|
|
53
|
+
const hex = expandHex(color).slice(1);
|
|
54
|
+
return {
|
|
55
|
+
r: Number.parseInt(hex.slice(0, 2), 16),
|
|
56
|
+
g: Number.parseInt(hex.slice(2, 4), 16),
|
|
57
|
+
b: Number.parseInt(hex.slice(4, 6), 16),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const pillStyle = (color?: string): JSX.CSSProperties | undefined => {
|
|
62
|
+
const rgb = color ? hexToRgb(color.trim()) : null;
|
|
63
|
+
if (!rgb) return undefined;
|
|
64
|
+
const { r, g, b } = rgb;
|
|
65
|
+
return {
|
|
66
|
+
"background-color": `rgba(${r}, ${g}, ${b}, 0.12)`,
|
|
67
|
+
color: `rgb(${r}, ${g}, ${b})`,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const iconColorStyle = (color?: string): JSX.CSSProperties | undefined =>
|
|
72
|
+
color && HEX_COLOR.test(color.trim()) ? { color: color.trim() } : undefined;
|
|
73
|
+
|
|
74
|
+
const MultiSelectInput = (props: MultiSelectInputProps) => {
|
|
75
|
+
const placeholder = () => props.placeholder ?? "Select...";
|
|
76
|
+
const icon = () => props.icon ?? "ti ti-chevron-down";
|
|
77
|
+
const activeIcon = () => props.activeIcon ?? "ti ti-chevron-up";
|
|
78
|
+
const disabled = () => props.disabled ?? false;
|
|
79
|
+
const clearable = () => props.clearable ?? false;
|
|
80
|
+
const value = () => props.value?.() ?? [];
|
|
81
|
+
const a11y = createInputA11y({ description: props.description, error: props.error });
|
|
82
|
+
const isSearchable = () => Boolean(props.fetchData);
|
|
83
|
+
|
|
84
|
+
const normalize = (option: MultiSelectOption): NormalizedOption =>
|
|
85
|
+
typeof option === "object" ? { ...option, label: option.label || option.id } : { id: option, label: option };
|
|
86
|
+
|
|
87
|
+
const [optionCache, setOptionCache] = createSignal<Record<string, NormalizedOption>>({});
|
|
88
|
+
const fetchMut = mutation.create<NormalizedOption[], string>({
|
|
89
|
+
mutation: async (query, { abortSignal }) => {
|
|
90
|
+
if (!props.fetchData) return [];
|
|
91
|
+
const raw = await props.fetchData(query, abortSignal);
|
|
92
|
+
return raw.map(normalize);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const triggerFetch = (query: string) => {
|
|
97
|
+
if (!props.fetchData) return;
|
|
98
|
+
fetchMut.abort();
|
|
99
|
+
void fetchMut.mutate(query);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const debounce = timed.debounce((q: string) => triggerFetch(q), props.fetchDebounceMs ?? 200);
|
|
103
|
+
|
|
104
|
+
const options = createMemo(() => {
|
|
105
|
+
if (isSearchable()) return fetchMut.data() ?? [];
|
|
106
|
+
return (props.options ?? []).map(normalize);
|
|
107
|
+
});
|
|
108
|
+
const selectedOptionHints = createMemo(() => (props.selectedOptions?.() ?? []).map(normalize));
|
|
109
|
+
const optionById = createMemo(() => {
|
|
110
|
+
const map = new Map<string, NormalizedOption>();
|
|
111
|
+
for (const option of Object.values(optionCache())) map.set(option.id, option);
|
|
112
|
+
for (const option of selectedOptionHints()) map.set(option.id, option);
|
|
113
|
+
for (const option of options()) map.set(option.id, option);
|
|
114
|
+
return map;
|
|
115
|
+
});
|
|
116
|
+
const selectedOptions = createMemo(() => value().map((id) => optionById().get(id) ?? { id, label: id }));
|
|
117
|
+
|
|
118
|
+
const [isOpen, setIsOpen] = createSignal(false);
|
|
119
|
+
const [query, setQuery] = createSignal("");
|
|
120
|
+
const [focusedIndex, setFocusedIndex] = createSignal(-1);
|
|
121
|
+
const [isDarkTheme, setIsDarkTheme] = createSignal(false);
|
|
122
|
+
|
|
123
|
+
let triggerRef: HTMLDivElement | undefined;
|
|
124
|
+
let dialogRef: HTMLDialogElement | undefined;
|
|
125
|
+
let searchInputRef: HTMLInputElement | undefined;
|
|
126
|
+
let optionRefs: HTMLDivElement[] = [];
|
|
127
|
+
|
|
128
|
+
const filteredOptions = createMemo(() => {
|
|
129
|
+
if (isSearchable()) return options();
|
|
130
|
+
const q = query().trim().toLowerCase();
|
|
131
|
+
if (!q) return options();
|
|
132
|
+
return options().filter((option) => [option.label, option.description, option.id].some((part) => part?.toLowerCase().includes(q)));
|
|
133
|
+
});
|
|
134
|
+
const waitingForRemoteOptions = () => isSearchable() && (fetchMut.loading() || Boolean(fetchMut.error()));
|
|
135
|
+
const visibleOptions = () => (waitingForRemoteOptions() ? [] : filteredOptions());
|
|
136
|
+
|
|
137
|
+
const isSelected = (id: string) => value().includes(id);
|
|
138
|
+
const nextWithout = (id: string) => value().filter((item) => item !== id);
|
|
139
|
+
const emit = (next: string[]) => props.onChange?.([...new Set(next)]);
|
|
140
|
+
|
|
141
|
+
const syncTheme = () => {
|
|
142
|
+
if (typeof document === "undefined") return;
|
|
143
|
+
setIsDarkTheme(document.documentElement.classList.contains("dark") || document.body.classList.contains("dark"));
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const focusOption = (index: number) => {
|
|
147
|
+
setFocusedIndex(index);
|
|
148
|
+
optionRefs[index]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const navigateOptions = (direction: "next" | "previous") => {
|
|
152
|
+
const count = filteredOptions().length;
|
|
153
|
+
if (!count) return;
|
|
154
|
+
const current = focusedIndex();
|
|
155
|
+
const next = direction === "next" ? (current < count - 1 ? current + 1 : 0) : current > 0 ? current - 1 : count - 1;
|
|
156
|
+
focusOption(next);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const positionDialog = () => {
|
|
160
|
+
if (!dialogRef || !triggerRef) return;
|
|
161
|
+
const rect = triggerRef.getBoundingClientRect();
|
|
162
|
+
const maxHeight = 320;
|
|
163
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
164
|
+
const spaceAbove = rect.top;
|
|
165
|
+
dialogRef.style.left = `${rect.left}px`;
|
|
166
|
+
dialogRef.style.width = `${rect.width}px`;
|
|
167
|
+
if (spaceBelow < maxHeight && spaceAbove > spaceBelow) {
|
|
168
|
+
dialogRef.style.top = "auto";
|
|
169
|
+
dialogRef.style.bottom = `${window.innerHeight - rect.top + 8}px`;
|
|
170
|
+
} else {
|
|
171
|
+
dialogRef.style.top = `${rect.bottom + 8}px`;
|
|
172
|
+
dialogRef.style.bottom = "auto";
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const toggleDropdown = (open: boolean) => {
|
|
177
|
+
if (disabled()) return;
|
|
178
|
+
syncTheme();
|
|
179
|
+
setIsOpen(open);
|
|
180
|
+
if (!open) {
|
|
181
|
+
dialogRef?.close();
|
|
182
|
+
setFocusedIndex(-1);
|
|
183
|
+
setQuery("");
|
|
184
|
+
if (isSearchable()) {
|
|
185
|
+
debounce.cancel();
|
|
186
|
+
fetchMut.abort();
|
|
187
|
+
}
|
|
188
|
+
triggerRef?.focus();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
setFocusedIndex(filteredOptions().length > 0 ? 0 : -1);
|
|
192
|
+
positionDialog();
|
|
193
|
+
dialogRef?.showModal();
|
|
194
|
+
if (isSearchable()) triggerFetch("");
|
|
195
|
+
queueMicrotask(() => searchInputRef?.focus());
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const toggleOption = (option: NormalizedOption) => {
|
|
199
|
+
setOptionCache({ ...optionCache(), [option.id]: option });
|
|
200
|
+
const next = isSelected(option.id) ? nextWithout(option.id) : [...value(), option.id];
|
|
201
|
+
emit(next);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const removeOption = (event: MouseEvent, id: string) => {
|
|
205
|
+
event.stopPropagation();
|
|
206
|
+
emit(nextWithout(id));
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const clearValue = (event: MouseEvent) => {
|
|
210
|
+
event.stopPropagation();
|
|
211
|
+
emit([]);
|
|
212
|
+
triggerRef?.focus();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
216
|
+
const open = isOpen();
|
|
217
|
+
switch (event.key) {
|
|
218
|
+
case "ArrowDown":
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
open ? navigateOptions("next") : toggleDropdown(true);
|
|
221
|
+
break;
|
|
222
|
+
case "ArrowUp":
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
if (open) navigateOptions("previous");
|
|
225
|
+
break;
|
|
226
|
+
case "Enter":
|
|
227
|
+
if (open && focusedIndex() >= 0) {
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
const option = filteredOptions()[focusedIndex()];
|
|
230
|
+
if (option) toggleOption(option);
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
case "Escape":
|
|
234
|
+
if (open) {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
toggleDropdown(false);
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
case "Backspace":
|
|
240
|
+
if (open && query().length === 0 && value().length > 0) {
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
emit(value().slice(0, -1));
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
case "Tab":
|
|
246
|
+
if (open) toggleDropdown(false);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const handleDialogClick = (event: MouseEvent) => {
|
|
252
|
+
if (event.target === dialogRef) toggleDropdown(false);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
onCleanup(() => dialogRef?.close());
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<InputWrapper
|
|
259
|
+
label={props.label}
|
|
260
|
+
description={props.description}
|
|
261
|
+
error={props.error?.()}
|
|
262
|
+
required={props.required}
|
|
263
|
+
inputId={a11y.inputId}
|
|
264
|
+
descriptionId={a11y.descriptionId}
|
|
265
|
+
errorId={a11y.errorId}
|
|
266
|
+
>
|
|
267
|
+
<div class="relative">
|
|
268
|
+
<div class="group relative flex-1">
|
|
269
|
+
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex -translate-y-1/2 items-center text-zinc-500">
|
|
270
|
+
<i class={`${isOpen() ? activeIcon() : icon()} ${isOpen() ? "text-blue-500" : ""}`} />
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div
|
|
274
|
+
ref={triggerRef}
|
|
275
|
+
id={a11y.inputId}
|
|
276
|
+
class={`input relative flex h-8 w-full items-center gap-1.5 overflow-hidden py-1.5 pl-9 pr-8 ${
|
|
277
|
+
isOpen() ? "!border-blue-500 !bg-white dark:!border-blue-400 dark:!bg-zinc-900" : ""
|
|
278
|
+
} ${disabled() ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
|
|
279
|
+
onClick={() => toggleDropdown(!isOpen())}
|
|
280
|
+
onKeyDown={handleKeyDown}
|
|
281
|
+
tabIndex={disabled() ? -1 : 0}
|
|
282
|
+
role="combobox"
|
|
283
|
+
aria-expanded={isOpen()}
|
|
284
|
+
aria-haspopup="listbox"
|
|
285
|
+
aria-label={!props.label ? "Select options" : undefined}
|
|
286
|
+
aria-describedby={a11y.ariaDescribedBy()}
|
|
287
|
+
aria-invalid={!!props.error?.()}
|
|
288
|
+
aria-required={props.required}
|
|
289
|
+
aria-disabled={disabled()}
|
|
290
|
+
>
|
|
291
|
+
<Show
|
|
292
|
+
when={selectedOptions().length > 0}
|
|
293
|
+
fallback={<span class="truncate text-zinc-400 dark:text-zinc-500">{placeholder()}</span>}
|
|
294
|
+
>
|
|
295
|
+
<div class="multi-select-pill-strip absolute inset-y-0 left-9 right-8 flex items-center gap-1 overflow-x-auto overflow-y-hidden whitespace-nowrap">
|
|
296
|
+
<For each={selectedOptions()}>
|
|
297
|
+
{(option) => (
|
|
298
|
+
<span
|
|
299
|
+
class="inline-flex max-w-36 shrink-0 items-center gap-1 rounded-full px-1 text-xs leading-5"
|
|
300
|
+
style={pillStyle(option.color)}
|
|
301
|
+
>
|
|
302
|
+
<Show when={option.icon}>
|
|
303
|
+
<i class={`${option.icon} text-[13px]`} />
|
|
304
|
+
</Show>
|
|
305
|
+
<span class="truncate">{option.label}</span>
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
class="-mr-0.5 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full opacity-70 hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
|
|
309
|
+
onClick={(event) => removeOption(event, option.id)}
|
|
310
|
+
tabIndex={-1}
|
|
311
|
+
aria-label={`Remove ${option.label}`}
|
|
312
|
+
>
|
|
313
|
+
<i class="ti ti-x text-[11px]" />
|
|
314
|
+
</button>
|
|
315
|
+
</span>
|
|
316
|
+
)}
|
|
317
|
+
</For>
|
|
318
|
+
</div>
|
|
319
|
+
</Show>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<Show when={clearable() && selectedOptions().length > 0 && !disabled()}>
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
class="absolute inset-y-0 right-2 flex items-center px-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
|
|
326
|
+
onClick={clearValue}
|
|
327
|
+
tabIndex={-1}
|
|
328
|
+
aria-label="Clear selection"
|
|
329
|
+
>
|
|
330
|
+
<i class="ti ti-x text-sm" />
|
|
331
|
+
</button>
|
|
332
|
+
</Show>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
<dialog
|
|
336
|
+
ref={dialogRef}
|
|
337
|
+
class="popup border border-zinc-200 p-1 backdrop:bg-transparent dark:border-zinc-700"
|
|
338
|
+
classList={{ dark: isDarkTheme() }}
|
|
339
|
+
onKeyDown={handleKeyDown}
|
|
340
|
+
onClick={handleDialogClick}
|
|
341
|
+
aria-label="Options"
|
|
342
|
+
>
|
|
343
|
+
<input
|
|
344
|
+
ref={searchInputRef}
|
|
345
|
+
type="text"
|
|
346
|
+
placeholder="Search..."
|
|
347
|
+
value={query()}
|
|
348
|
+
onInput={(event) => {
|
|
349
|
+
const next = event.currentTarget.value;
|
|
350
|
+
setQuery(next);
|
|
351
|
+
setFocusedIndex(0);
|
|
352
|
+
if (isSearchable()) {
|
|
353
|
+
debounce.debouncedFn(next);
|
|
354
|
+
} else {
|
|
355
|
+
setFocusedIndex(filteredOptions().length > 0 ? 0 : -1);
|
|
356
|
+
}
|
|
357
|
+
}}
|
|
358
|
+
class="w-full border-0 bg-transparent px-3 py-1.5 text-sm text-zinc-700 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-300 dark:placeholder:text-zinc-500"
|
|
359
|
+
aria-label="Search options"
|
|
360
|
+
/>
|
|
361
|
+
<div
|
|
362
|
+
class="flex max-h-72 flex-col gap-1 overflow-y-auto"
|
|
363
|
+
role="listbox"
|
|
364
|
+
aria-label={props.label || "Options"}
|
|
365
|
+
aria-multiselectable="true"
|
|
366
|
+
>
|
|
367
|
+
<Show when={isSearchable() && fetchMut.loading()}>
|
|
368
|
+
<div class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
|
369
|
+
<i class="ti ti-loader-2 animate-spin" /> Loading...
|
|
370
|
+
</div>
|
|
371
|
+
</Show>
|
|
372
|
+
<Show when={isSearchable() && !fetchMut.loading() && fetchMut.error()}>
|
|
373
|
+
<div class="flex items-center gap-2 px-3 py-1.5 text-xs text-red-500">
|
|
374
|
+
<i class="ti ti-alert-triangle shrink-0" />
|
|
375
|
+
<span class="flex-1 truncate">{fetchMut.error()?.message ?? "Failed to load"}</span>
|
|
376
|
+
<button
|
|
377
|
+
type="button"
|
|
378
|
+
class="text-zinc-500 underline hover:text-zinc-700 dark:hover:text-zinc-300"
|
|
379
|
+
onClick={() => fetchMut.retry()}
|
|
380
|
+
>
|
|
381
|
+
Retry
|
|
382
|
+
</button>
|
|
383
|
+
</div>
|
|
384
|
+
</Show>
|
|
385
|
+
<For
|
|
386
|
+
each={visibleOptions()}
|
|
387
|
+
fallback={
|
|
388
|
+
<Show when={!waitingForRemoteOptions()}>
|
|
389
|
+
<div class="px-3 py-1.5 text-xs text-zinc-400 dark:text-zinc-500">
|
|
390
|
+
{isSearchable() ? "No results" : "No options available"}
|
|
391
|
+
</div>
|
|
392
|
+
</Show>
|
|
393
|
+
}
|
|
394
|
+
>
|
|
395
|
+
{(option, index) => {
|
|
396
|
+
const selected = () => isSelected(option.id);
|
|
397
|
+
const focused = () => index() === focusedIndex();
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<div
|
|
401
|
+
ref={(el) => (optionRefs[index()] = el)}
|
|
402
|
+
class={`group flex cursor-pointer select-none items-center gap-3 rounded px-2 py-2 text-sm transition-colors ${
|
|
403
|
+
focused() ? "bg-blue-50 dark:bg-blue-950/35" : "hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
|
404
|
+
}`}
|
|
405
|
+
onClick={() => toggleOption(option)}
|
|
406
|
+
onKeyDown={(event) => {
|
|
407
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
408
|
+
event.preventDefault();
|
|
409
|
+
toggleOption(option);
|
|
410
|
+
}
|
|
411
|
+
}}
|
|
412
|
+
onMouseEnter={() => setFocusedIndex(index())}
|
|
413
|
+
role="option"
|
|
414
|
+
aria-label={option.label}
|
|
415
|
+
aria-selected={selected()}
|
|
416
|
+
tabIndex={-1}
|
|
417
|
+
>
|
|
418
|
+
<span
|
|
419
|
+
class={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
|
420
|
+
selected()
|
|
421
|
+
? "border-blue-500 bg-blue-500 text-white"
|
|
422
|
+
: "border-zinc-300 bg-white text-transparent dark:border-zinc-600 dark:bg-zinc-900"
|
|
423
|
+
}`}
|
|
424
|
+
>
|
|
425
|
+
<i class="ti ti-check text-[12px]" />
|
|
426
|
+
</span>
|
|
427
|
+
<Show when={option.icon}>
|
|
428
|
+
<i class={`${option.icon} shrink-0 text-zinc-500 dark:text-zinc-400`} style={iconColorStyle(option.color)} />
|
|
429
|
+
</Show>
|
|
430
|
+
<div class="min-w-0 flex-1">
|
|
431
|
+
<div class="truncate text-zinc-800 dark:text-zinc-200">{option.label}</div>
|
|
432
|
+
<Show when={option.description}>
|
|
433
|
+
<div class="mt-0.5 truncate text-xs text-zinc-500 dark:text-zinc-400">{option.description}</div>
|
|
434
|
+
</Show>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
438
|
+
}}
|
|
439
|
+
</For>
|
|
440
|
+
</div>
|
|
441
|
+
</dialog>
|
|
442
|
+
</div>
|
|
443
|
+
</InputWrapper>
|
|
444
|
+
);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
export { MultiSelectInput };
|
|
448
|
+
export default MultiSelectInput;
|