@marianmeres/stuic 3.86.0 → 3.88.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 (64) hide show
  1. package/API.md +41 -0
  2. package/dist/actions/validate.svelte.d.ts +24 -4
  3. package/dist/actions/validate.svelte.js +18 -5
  4. package/dist/components/EmailVerifyForm/EmailVerifyForm.svelte +21 -0
  5. package/dist/components/EmailVerifyForm/EmailVerifyForm.svelte.d.ts +4 -1
  6. package/dist/components/Input/FieldAssets.svelte +48 -3
  7. package/dist/components/Input/FieldAssets.svelte.d.ts +8 -2
  8. package/dist/components/Input/FieldCheckbox.svelte +34 -3
  9. package/dist/components/Input/FieldCheckbox.svelte.d.ts +8 -1
  10. package/dist/components/Input/FieldCountry.svelte +64 -7
  11. package/dist/components/Input/FieldCountry.svelte.d.ts +8 -1
  12. package/dist/components/Input/FieldFile.svelte +34 -3
  13. package/dist/components/Input/FieldFile.svelte.d.ts +8 -1
  14. package/dist/components/Input/FieldInput.svelte +43 -3
  15. package/dist/components/Input/FieldInput.svelte.d.ts +8 -1
  16. package/dist/components/Input/FieldInputLocalized.svelte +41 -2
  17. package/dist/components/Input/FieldInputLocalized.svelte.d.ts +8 -2
  18. package/dist/components/Input/FieldKeyValues.svelte +37 -2
  19. package/dist/components/Input/FieldKeyValues.svelte.d.ts +8 -2
  20. package/dist/components/Input/FieldLikeButton.svelte +41 -4
  21. package/dist/components/Input/FieldLikeButton.svelte.d.ts +8 -1
  22. package/dist/components/Input/FieldObject.svelte +64 -6
  23. package/dist/components/Input/FieldObject.svelte.d.ts +8 -2
  24. package/dist/components/Input/FieldOptions.svelte +36 -3
  25. package/dist/components/Input/FieldOptions.svelte.d.ts +8 -2
  26. package/dist/components/Input/FieldPhoneNumber.svelte +51 -6
  27. package/dist/components/Input/FieldPhoneNumber.svelte.d.ts +8 -1
  28. package/dist/components/Input/FieldRadios.svelte +36 -2
  29. package/dist/components/Input/FieldRadios.svelte.d.ts +8 -1
  30. package/dist/components/Input/FieldSelect.svelte +34 -3
  31. package/dist/components/Input/FieldSelect.svelte.d.ts +8 -1
  32. package/dist/components/Input/FieldSwitch.svelte +41 -2
  33. package/dist/components/Input/FieldSwitch.svelte.d.ts +8 -1
  34. package/dist/components/Input/FieldTextarea.svelte +34 -3
  35. package/dist/components/Input/FieldTextarea.svelte.d.ts +8 -1
  36. package/dist/components/Input/_internal/FieldRadioInternal.svelte +34 -3
  37. package/dist/components/Input/_internal/FieldRadioInternal.svelte.d.ts +7 -1
  38. package/dist/components/LoginForm/LoginForm.svelte +35 -0
  39. package/dist/components/LoginForm/LoginForm.svelte.d.ts +5 -1
  40. package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte +40 -0
  41. package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte.d.ts +5 -1
  42. package/dist/components/RegisterForm/RegisterForm.svelte +46 -2
  43. package/dist/components/RegisterForm/RegisterForm.svelte.d.ts +5 -1
  44. package/dist/components/Switch/Switch.svelte +42 -4
  45. package/dist/components/Switch/Switch.svelte.d.ts +7 -1
  46. package/dist/components/UserAvatarMenu/README.md +188 -0
  47. package/dist/components/UserAvatarMenu/UserAvatarMenu.svelte +416 -0
  48. package/dist/components/UserAvatarMenu/UserAvatarMenu.svelte.d.ts +143 -0
  49. package/dist/components/UserAvatarMenu/index.css +95 -0
  50. package/dist/components/UserAvatarMenu/index.d.ts +1 -0
  51. package/dist/components/UserAvatarMenu/index.js +1 -0
  52. package/dist/icons/index.d.ts +3 -0
  53. package/dist/icons/index.js +3 -0
  54. package/dist/index.css +1 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +1 -0
  57. package/dist/utils/index.d.ts +1 -0
  58. package/dist/utils/index.js +1 -0
  59. package/dist/utils/validate-fields.d.ts +72 -0
  60. package/dist/utils/validate-fields.js +73 -0
  61. package/docs/domains/actions.md +74 -0
  62. package/docs/domains/components.md +190 -0
  63. package/docs/domains/utils.md +38 -0
  64. package/package.json +1 -1
@@ -0,0 +1,188 @@
1
+ # UserAvatarMenu
2
+
3
+ A thin, opinionated wrapper around [`Avatar`](../Avatar/) + [`DropdownMenu`](../DropdownMenu/) for the common "user avatar in the header that opens a small menu" pattern. Renders sensibly in both authenticated and unauthenticated states from the same trigger position.
4
+
5
+ The component composes existing primitives and adds convention, not behavior. It owns **no** auth state, router, or i18n — consumers pass in the user (or `null`) and the handlers.
6
+
7
+ ## Usage
8
+
9
+ ### Minimal — logged in
10
+
11
+ ```svelte
12
+ <UserAvatarMenu
13
+ identity={{ email: user.email }}
14
+ actions={{
15
+ onProfile: () => goto("/me"),
16
+ onLogout: () => goto("/logout"),
17
+ }}
18
+ />
19
+ ```
20
+
21
+ ### Minimal — logged out
22
+
23
+ ```svelte
24
+ <UserAvatarMenu
25
+ actions={{
26
+ onLogin: () => openLoginModal(),
27
+ onRegister: () => openRegisterModal(),
28
+ }}
29
+ />
30
+ ```
31
+
32
+ ### Full (i18n + extras)
33
+
34
+ ```svelte
35
+ <UserAvatarMenu
36
+ identity={{
37
+ email: user.email,
38
+ roles: user.roles,
39
+ }}
40
+ showRoles
41
+ actions={{
42
+ onProfile: () => goto("/account/profile"),
43
+ onLogout: () => goto("/logout"),
44
+ }}
45
+ labels={{
46
+ viewProfile: t.raw("dd_view_profile"),
47
+ logout: t.raw("dd_logout"),
48
+ lightMode: t.raw("dd_light_mode"),
49
+ darkMode: t.raw("dd_dark_mode"),
50
+ }}
51
+ avatar={{ class: "size-[60px]", padding: "10px" }}
52
+ classDropdown="min-w-56 rounded-lg border-primary border-3 border-solid"
53
+ />
54
+ ```
55
+
56
+ ## Props
57
+
58
+ | Prop | Type | Default | Description |
59
+ | ---------------- | --------------------------------------------- | ------- | ------------------------------------------------------------------------------------------- |
60
+ | `identity` | `UserAvatarMenuIdentity \| null` | `null` | Current user. `null` / `undefined` → unauthenticated mode. |
61
+ | `actions` | `UserAvatarMenuActions` | `{}` | Handlers: `onProfile`, `onSettings`, `onLogout`, `onLogin`, `onRegister`. Missing → hidden. |
62
+ | `labels` | `UserAvatarMenuLabels` | English | Translated strings for built-in items. |
63
+ | `colorScheme` | `boolean \| { enabled?; onToggle?; isDark? }` | `true` | Built-in dark/light toggle. `false` to disable. Object form overrides toggle / read. |
64
+ | `showHeaderTile` | `boolean` | `true` | Render the avatar + email header tile when authenticated. |
65
+ | `showRoles` | `boolean` | `false` | Render `identity.roles` under the email in the header tile. |
66
+ | `extraItems` | `DropdownMenuItem[]` | — | Appended to the standard item set. |
67
+ | `items` | `DropdownMenuItem[]` | — | Full override of the item list. Trigger + dropdown shell still render. |
68
+ | `avatar` | `Partial<AvatarProps>` | — | Forwarded to the default trigger Avatar (and the header-tile Avatar). |
69
+ | `position` | `DropdownMenuPosition` | — | Forwarded to `DropdownMenu`. |
70
+ | `offset` | `string` | — | Forwarded. |
71
+ | `maxHeight` | `string` | — | Forwarded. |
72
+ | `closeOnSelect` | `boolean` | — | Forwarded. |
73
+ | `classDropdown` | `string` | — | Forwarded. |
74
+ | `classTrigger` | `string` | — | Class merged onto the default trigger `<button>`. |
75
+ | `isOpen` | `boolean` | `false` | Bindable open state. |
76
+ | `trigger` | `Snippet<[{ isOpen; toggle; triggerProps }]>` | — | Custom trigger snippet; fully replaces the default. |
77
+ | `headerTile` | `Snippet<[{ identity }]>` | — | Custom header-tile snippet; replaces the default avatar+email tile inside the dropdown. |
78
+ | `unstyled` | `boolean` | `false` | Skip default styling. |
79
+ | `class` | `string` | — | Wrapper classes. |
80
+ | `el` | `HTMLDivElement` | — | Bindable wrapper element. |
81
+
82
+ ### `UserAvatarMenuIdentity`
83
+
84
+ ```ts
85
+ interface UserAvatarMenuIdentity {
86
+ email: string;
87
+ name?: string;
88
+ src?: string; // photo URL
89
+ roles?: string[];
90
+ }
91
+ ```
92
+
93
+ ### `UserAvatarMenuActions`
94
+
95
+ All handlers are optional. **Omitting a handler hides the corresponding item.** The three unauth handlers (`onLoginOrRegister`, `onLogin`, `onRegister`) are independent — pass any combination.
96
+
97
+ | Handler | Item | Visible in |
98
+ | ------------------- | ----------------- | --------------- |
99
+ | `onProfile` | View profile | Authenticated |
100
+ | `onSettings` | Settings | Authenticated |
101
+ | `onLogout` | Logout | Authenticated |
102
+ | `onLoginOrRegister` | Login or register | Unauthenticated |
103
+ | `onLogin` | Login | Unauthenticated |
104
+ | `onRegister` | Register | Unauthenticated |
105
+
106
+ ### `UserAvatarMenuLabels`
107
+
108
+ ```ts
109
+ interface UserAvatarMenuLabels {
110
+ viewProfile?: string; // "View profile"
111
+ settings?: string; // "Settings"
112
+ logout?: string; // "Logout"
113
+ loginOrRegister?: string; // "Login or register"
114
+ login?: string; // "Login"
115
+ register?: string; // "Register"
116
+ lightMode?: string; // "Light mode"
117
+ darkMode?: string; // "Dark mode"
118
+ triggerAuthed?: string; // "User menu" (aria-label)
119
+ triggerAnon?: string; // "Sign in" (aria-label)
120
+ }
121
+ ```
122
+
123
+ ## Default item order
124
+
125
+ Predictable so consumers can reason about position when adding `extraItems`.
126
+
127
+ ### Authenticated
128
+
129
+ 1. Header tile (when `showHeaderTile !== false`)
130
+ 2. View profile (when `actions.onProfile`)
131
+ 3. Settings (when `actions.onSettings`)
132
+ 4. Color scheme toggle (when `colorScheme !== false`) — sun/moon icon, label flips
133
+ 5. Divider (when there was at least one item above **and** `actions.onLogout`)
134
+ 6. Logout (when `actions.onLogout`) — with `iconLogOut`
135
+ 7. `extraItems` (appended)
136
+
137
+ ### Unauthenticated
138
+
139
+ 1. Login or register (when `actions.onLoginOrRegister`) — the typical case, wire to a `LoginOrRegisterFormModal`
140
+ 2. Login (when `actions.onLogin`)
141
+ 3. Register (when `actions.onRegister`)
142
+ 4. Color scheme toggle
143
+ 5. `extraItems`
144
+
145
+ No header tile in unauth mode.
146
+
147
+ ### `items` override
148
+
149
+ When `items` is passed, the entire default set is bypassed. The trigger + shell still render.
150
+
151
+ ## Opinionated decisions
152
+
153
+ - **Color scheme is the one built-in side effect.** The component calls `ColorScheme.toggle()` and re-reads `ColorScheme.getValue()` on select. Label/icon flip without consumer involvement. Opt out with `colorScheme={false}`, or override via the object form: `colorScheme={{ onToggle, isDark }}`.
154
+ - **English defaults.** Pass `labels` for i18n. The component never imports an i18n library; consumers pass already-translated strings.
155
+ - **No auth ownership.** No state observation, no log-out call, no router. All side effects are consumer callbacks.
156
+ - **No modal triggering.** "Login" doesn't open a `LoginOrRegisterFormModal` — that's your `onLogin` handler.
157
+ - **Trigger is just `Avatar`.** No wrapper element by default. In unauth mode `Avatar` falls back to `iconUser`. Use the `trigger` snippet for full control.
158
+
159
+ ## CSS Tokens
160
+
161
+ Prefix: `--stuic-user-avatar-menu-*`
162
+
163
+ | Token | Default |
164
+ | ------------------------------------------------- | ------------------------------------- |
165
+ | `--stuic-user-avatar-menu-dropdown-width` | `16rem` |
166
+ | `--stuic-user-avatar-menu-trigger-radius` | `var(--stuic-radius)` |
167
+ | `--stuic-user-avatar-menu-trigger-opacity-hover` | `0.85` |
168
+ | `--stuic-user-avatar-menu-trigger-outline-color` | `var(--stuic-color-primary)` |
169
+ | `--stuic-user-avatar-menu-transition` | `var(--stuic-transition)` |
170
+ | `--stuic-user-avatar-menu-header-gap` | `0.5rem` |
171
+ | `--stuic-user-avatar-menu-header-padding` | `0.75rem 0.5rem` |
172
+ | `--stuic-user-avatar-menu-header-margin-bottom` | `0.25rem` |
173
+ | `--stuic-user-avatar-menu-header-bg` | `var(--stuic-color-muted)` |
174
+ | `--stuic-user-avatar-menu-header-color` | `var(--stuic-color-muted-foreground)` |
175
+ | `--stuic-user-avatar-menu-header-radius` | `var(--stuic-radius)` |
176
+ | `--stuic-user-avatar-menu-header-email-font-size` | `inherit` |
177
+ | `--stuic-user-avatar-menu-header-email-color` | `inherit` |
178
+ | `--stuic-user-avatar-menu-header-roles-font-size` | `0.75rem` |
179
+ | `--stuic-user-avatar-menu-header-roles-color` | `var(--stuic-color-muted-foreground)` |
180
+ | `--stuic-user-avatar-menu-header-roles-opacity` | `0.7` |
181
+
182
+ The dropdown has a fixed `width` so every instance opens at the same size, regardless of content. Long emails/names truncate with `text-overflow: ellipsis` against that fixed width. Override `--stuic-user-avatar-menu-dropdown-width` (or pass `classDropdown="!w-72"` for one-off overrides).
183
+
184
+ ## See also
185
+
186
+ - [`Avatar`](../Avatar/) — trigger / header-tile primitive
187
+ - [`DropdownMenu`](../DropdownMenu/) — menu shell (item shapes, position values, search)
188
+ - [`ColorScheme`](../ColorScheme/) — class used by the built-in toggle
@@ -0,0 +1,416 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type {
4
+ DropdownMenuItem,
5
+ DropdownMenuPosition,
6
+ } from "../DropdownMenu/DropdownMenu.svelte";
7
+ import type { Props as AvatarProps } from "../Avatar/Avatar.svelte";
8
+
9
+ /**
10
+ * Identity passed by the consumer. When `null` / `undefined`, the menu
11
+ * renders in unauthenticated mode. When provided, the menu renders the
12
+ * header tile (avatar + email) and the authenticated item set.
13
+ */
14
+ export interface UserAvatarMenuIdentity {
15
+ /** Used for initials + auto-color hashing. Required when `identity` is set. */
16
+ email: string;
17
+ /** Optional display name (preferred over email in the header tile if present). */
18
+ name?: string;
19
+ /** Optional photo URL (forwarded to Avatar `src`). */
20
+ src?: string;
21
+ /** Optional role list — rendered under the email if `showRoles` is enabled. */
22
+ roles?: string[];
23
+ }
24
+
25
+ /**
26
+ * Standard built-in actions. Consumers wire these by passing handlers;
27
+ * omitting a handler hides the corresponding item.
28
+ *
29
+ * The component never navigates, logs out, or toggles theme on its own —
30
+ * it only invokes callbacks. The only built-in side effect is
31
+ * `ColorScheme.toggle()` inside the color-scheme item (see `colorScheme`).
32
+ */
33
+ export interface UserAvatarMenuActions {
34
+ /** "View profile" item. Hidden when not provided. (Auth state only.) */
35
+ onProfile?: () => void;
36
+ /** "Settings" item. Hidden when not provided. (Auth state only.) */
37
+ onSettings?: () => void;
38
+ /** "Logout" item. Hidden when not provided. (Auth state only.) */
39
+ onLogout?: () => void;
40
+ /**
41
+ * "Login or register" item — combined affordance, typically wired to
42
+ * open a `LoginOrRegisterFormModal`. Hidden when not provided.
43
+ * (Unauth state only.) Independent of `onLogin` / `onRegister`; pass
44
+ * any combination of the three.
45
+ */
46
+ onLoginOrRegister?: () => void;
47
+ /** "Login" item. Hidden when not provided. (Unauth state only.) */
48
+ onLogin?: () => void;
49
+ /** "Register" item. Hidden when not provided. (Unauth state only.) */
50
+ onRegister?: () => void;
51
+ }
52
+
53
+ /**
54
+ * Labels for built-in items. All optional — defaults are English strings.
55
+ * Consumers running i18n pass already-translated strings here.
56
+ */
57
+ export interface UserAvatarMenuLabels {
58
+ viewProfile?: string;
59
+ settings?: string;
60
+ logout?: string;
61
+ loginOrRegister?: string;
62
+ login?: string;
63
+ register?: string;
64
+ lightMode?: string;
65
+ darkMode?: string;
66
+ /** Trigger aria-label when authenticated. Default: "User menu". */
67
+ triggerAuthed?: string;
68
+ /** Trigger aria-label when unauthenticated. Default: "Sign in". */
69
+ triggerAnon?: string;
70
+ }
71
+
72
+ /**
73
+ * Color-scheme item config. Defaults to enabled.
74
+ * Pass `false` to disable; pass an object to customize.
75
+ */
76
+ export type UserAvatarMenuColorScheme =
77
+ | boolean
78
+ | {
79
+ enabled?: boolean;
80
+ /** Override the default `ColorScheme.toggle()` behavior. */
81
+ onToggle?: () => void;
82
+ /** Read current "is dark" state. Defaults to `ColorScheme.getValue() === "dark"`. */
83
+ isDark?: () => boolean;
84
+ };
85
+
86
+ export interface Props {
87
+ /** Current user. `null` / `undefined` → unauthenticated mode. */
88
+ identity?: UserAvatarMenuIdentity | null;
89
+
90
+ /** Action handlers (see `UserAvatarMenuActions`). */
91
+ actions?: UserAvatarMenuActions;
92
+
93
+ /** Translated / customized labels. */
94
+ labels?: UserAvatarMenuLabels;
95
+
96
+ /** Color-scheme toggle config. Default: enabled. */
97
+ colorScheme?: UserAvatarMenuColorScheme;
98
+
99
+ /** Render the identity header tile (avatar + email) in the dropdown. Default: `true`. */
100
+ showHeaderTile?: boolean;
101
+
102
+ /** Render roles under the email in the header tile. Default: `false`. */
103
+ showRoles?: boolean;
104
+
105
+ /**
106
+ * Extra items appended to the standard set (after Logout / Register).
107
+ * Use for app-specific actions ("Switch project", "Billing", etc.).
108
+ */
109
+ extraItems?: DropdownMenuItem[];
110
+
111
+ /**
112
+ * Full override. When provided, the standard item set is IGNORED and the
113
+ * component renders exactly these items. `identity` is still consulted for
114
+ * the trigger; everything else (`actions`, `labels`, `colorScheme`,
115
+ * `showHeaderTile`, `showRoles`) is not.
116
+ */
117
+ items?: DropdownMenuItem[];
118
+
119
+ /** Forwarded to the default `Avatar` trigger and header-tile avatar. */
120
+ avatar?: Partial<Omit<AvatarProps, "onclick" | "initials" | "src" | "el">>;
121
+
122
+ /** Forwarded to `DropdownMenu`. */
123
+ position?: DropdownMenuPosition;
124
+ offset?: string;
125
+ maxHeight?: string;
126
+ closeOnSelect?: boolean;
127
+ classDropdown?: string;
128
+ classTrigger?: string;
129
+
130
+ /** Bindable open state (forwarded to `DropdownMenu`). */
131
+ isOpen?: boolean;
132
+
133
+ /**
134
+ * Optional custom trigger snippet. Receives the same args as
135
+ * `DropdownMenu`'s `trigger` snippet — `isOpen`, `toggle`, `triggerProps`.
136
+ */
137
+ trigger?: Snippet<
138
+ [
139
+ {
140
+ isOpen: boolean;
141
+ toggle: () => void;
142
+ triggerProps: {
143
+ id: string;
144
+ "aria-haspopup": "menu";
145
+ "aria-expanded": boolean;
146
+ "aria-controls": string;
147
+ };
148
+ },
149
+ ]
150
+ >;
151
+
152
+ /**
153
+ * Optional custom header-tile snippet. Replaces the default
154
+ * avatar + email tile rendered at the top of the menu.
155
+ */
156
+ headerTile?: Snippet<[{ identity: UserAvatarMenuIdentity }]>;
157
+
158
+ /** Skip default styling. */
159
+ unstyled?: boolean;
160
+ /** Additional CSS classes on the wrapper. */
161
+ class?: string;
162
+ /** Bindable wrapper element. */
163
+ el?: HTMLDivElement;
164
+ }
165
+ </script>
166
+
167
+ <script lang="ts">
168
+ import { twMerge } from "../../utils/tw-merge.js";
169
+ import { iconLogOut, iconMoon, iconSun } from "../../icons/index.js";
170
+ import Avatar from "../Avatar/Avatar.svelte";
171
+ import DropdownMenu from "../DropdownMenu/DropdownMenu.svelte";
172
+ import { ColorScheme } from "../ColorScheme/index.js";
173
+
174
+ let {
175
+ identity = null,
176
+ actions = {},
177
+ labels = {},
178
+ colorScheme = true,
179
+ showHeaderTile = true,
180
+ showRoles = false,
181
+ extraItems,
182
+ items: itemsOverride,
183
+ avatar: avatarOverrides,
184
+ position,
185
+ offset,
186
+ maxHeight,
187
+ closeOnSelect,
188
+ classDropdown,
189
+ classTrigger,
190
+ isOpen = $bindable(false),
191
+ trigger,
192
+ headerTile,
193
+ unstyled = false,
194
+ class: classProp,
195
+ el = $bindable(),
196
+ }: Props = $props();
197
+
198
+ const isAuthed = $derived(!!identity);
199
+
200
+ // Color-scheme config normalization
201
+ const cs = $derived(
202
+ typeof colorScheme === "object" && colorScheme !== null ? colorScheme : {}
203
+ );
204
+ const csEnabled = $derived(
205
+ colorScheme === false ? false : cs.enabled !== false /* default true */
206
+ );
207
+
208
+ function readIsDark(): boolean {
209
+ return cs.isDark ? cs.isDark() : ColorScheme.getValue() === "dark";
210
+ }
211
+
212
+ let isDark = $state(false);
213
+ $effect(() => {
214
+ if (csEnabled) isDark = readIsDark();
215
+ });
216
+
217
+ function toggleColorScheme() {
218
+ if (cs.onToggle) {
219
+ cs.onToggle();
220
+ } else {
221
+ ColorScheme.toggle();
222
+ }
223
+ isDark = readIsDark();
224
+ }
225
+
226
+ // Default labels (English)
227
+ const L = $derived({
228
+ viewProfile: labels.viewProfile ?? "View profile",
229
+ settings: labels.settings ?? "Settings",
230
+ logout: labels.logout ?? "Logout",
231
+ loginOrRegister: labels.loginOrRegister ?? "Login or register",
232
+ login: labels.login ?? "Login",
233
+ register: labels.register ?? "Register",
234
+ lightMode: labels.lightMode ?? "Light mode",
235
+ darkMode: labels.darkMode ?? "Dark mode",
236
+ triggerAuthed: labels.triggerAuthed ?? "User menu",
237
+ triggerAnon: labels.triggerAnon ?? "Sign in",
238
+ });
239
+
240
+ function buildColorSchemeItem(): DropdownMenuItem {
241
+ return {
242
+ type: "action",
243
+ id: "color-scheme",
244
+ label: isDark ? L.lightMode : L.darkMode,
245
+ contentBefore: {
246
+ html: isDark ? iconSun({ size: 16 }) : iconMoon({ size: 16 }),
247
+ },
248
+ onSelect: toggleColorScheme,
249
+ };
250
+ }
251
+
252
+ const computedItems = $derived.by((): DropdownMenuItem[] => {
253
+ if (itemsOverride) return itemsOverride;
254
+
255
+ const out: DropdownMenuItem[] = [];
256
+
257
+ if (isAuthed) {
258
+ if (showHeaderTile) {
259
+ out.push({
260
+ type: "custom",
261
+ id: "header-tile",
262
+ content: { snippet: renderHeaderTile },
263
+ });
264
+ }
265
+
266
+ let renderedAny = false;
267
+ if (actions.onProfile) {
268
+ out.push({
269
+ type: "action",
270
+ id: "profile",
271
+ label: L.viewProfile,
272
+ onSelect: actions.onProfile,
273
+ });
274
+ renderedAny = true;
275
+ }
276
+ if (actions.onSettings) {
277
+ out.push({
278
+ type: "action",
279
+ id: "settings",
280
+ label: L.settings,
281
+ onSelect: actions.onSettings,
282
+ });
283
+ renderedAny = true;
284
+ }
285
+ if (csEnabled) {
286
+ out.push(buildColorSchemeItem());
287
+ renderedAny = true;
288
+ }
289
+
290
+ if (renderedAny && actions.onLogout) {
291
+ out.push({ type: "divider", id: "div-logout" });
292
+ }
293
+
294
+ if (actions.onLogout) {
295
+ out.push({
296
+ type: "action",
297
+ id: "logout",
298
+ label: L.logout,
299
+ contentBefore: { html: iconLogOut({ size: 16 }) },
300
+ onSelect: actions.onLogout,
301
+ });
302
+ }
303
+ } else {
304
+ if (actions.onLoginOrRegister) {
305
+ out.push({
306
+ type: "action",
307
+ id: "login-or-register",
308
+ label: L.loginOrRegister,
309
+ onSelect: actions.onLoginOrRegister,
310
+ });
311
+ }
312
+ if (actions.onLogin) {
313
+ out.push({
314
+ type: "action",
315
+ id: "login",
316
+ label: L.login,
317
+ onSelect: actions.onLogin,
318
+ });
319
+ }
320
+ if (actions.onRegister) {
321
+ out.push({
322
+ type: "action",
323
+ id: "register",
324
+ label: L.register,
325
+ onSelect: actions.onRegister,
326
+ });
327
+ }
328
+ if (csEnabled) out.push(buildColorSchemeItem());
329
+ }
330
+
331
+ if (extraItems?.length) out.push(...extraItems);
332
+ return out;
333
+ });
334
+
335
+ const wrapperClass = $derived(
336
+ twMerge(!unstyled && "stuic-user-avatar-menu", classProp)
337
+ );
338
+ </script>
339
+
340
+ {#snippet defaultHeaderTile()}
341
+ {#if identity}
342
+ <div class={!unstyled ? "stuic-user-avatar-menu-header" : undefined}>
343
+ <Avatar
344
+ initials={identity.email}
345
+ initialsLength={1}
346
+ autoColor
347
+ hashSource={identity.email}
348
+ src={identity.src}
349
+ onclick={actions.onProfile}
350
+ {...avatarOverrides}
351
+ />
352
+ <div class={!unstyled ? "stuic-user-avatar-menu-header-email" : undefined}>
353
+ {identity.name ?? identity.email}
354
+ </div>
355
+ {#if showRoles && identity.roles?.length}
356
+ <div class={!unstyled ? "stuic-user-avatar-menu-header-roles" : undefined}>
357
+ {identity.roles.join(", ")}
358
+ </div>
359
+ {/if}
360
+ </div>
361
+ {/if}
362
+ {/snippet}
363
+
364
+ {#snippet renderHeaderTile(_args: Record<string, any>)}
365
+ {#if identity}
366
+ {#if headerTile}
367
+ {@render headerTile({ identity })}
368
+ {:else}
369
+ {@render defaultHeaderTile()}
370
+ {/if}
371
+ {/if}
372
+ {/snippet}
373
+
374
+ {#snippet defaultTrigger({
375
+ toggle,
376
+ triggerProps,
377
+ }: {
378
+ isOpen: boolean;
379
+ toggle: () => void;
380
+ triggerProps: Record<string, any>;
381
+ })}
382
+ <button
383
+ type="button"
384
+ onclick={toggle}
385
+ aria-label={isAuthed ? L.triggerAuthed : L.triggerAnon}
386
+ class={twMerge(!unstyled && "stuic-user-avatar-menu-trigger", classTrigger)}
387
+ {...triggerProps}
388
+ >
389
+ {#if isAuthed && identity}
390
+ <Avatar
391
+ initials={identity.email}
392
+ initialsLength={1}
393
+ autoColor
394
+ hashSource={identity.email}
395
+ src={identity.src}
396
+ {...avatarOverrides}
397
+ />
398
+ {:else}
399
+ <Avatar {...avatarOverrides} />
400
+ {/if}
401
+ </button>
402
+ {/snippet}
403
+
404
+ <div bind:this={el} class={wrapperClass}>
405
+ <DropdownMenu
406
+ items={computedItems}
407
+ bind:isOpen
408
+ {position}
409
+ {offset}
410
+ {maxHeight}
411
+ {closeOnSelect}
412
+ classDropdown={twMerge(!unstyled && "stuic-user-avatar-menu-dropdown", classDropdown)}
413
+ {unstyled}
414
+ trigger={trigger ?? defaultTrigger}
415
+ />
416
+ </div>