@marianmeres/stuic 3.95.0 → 3.97.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/AGENTS.md CHANGED
@@ -23,15 +23,16 @@
23
23
 
24
24
  ```
25
25
  src/lib/
26
- ├── components/ # 56 UI components
26
+ ├── components/ # 57 component directories
27
27
  ├── actions/ # 15 Svelte actions
28
- ├── utils/ # 43 utility modules
29
- ├── themes/ # Generated theme CSS (css/) — definitions from @marianmeres/design-tokens
30
- ├── icons/ # Icon re-exports
28
+ ├── utils/ # 44 utility modules
29
+ ├── icons/ # Icon re-exports from @marianmeres/icons-fns
31
30
  ├── index.css # Centralized CSS imports
32
31
  └── index.ts # Main exports
33
32
  ```
34
33
 
34
+ Theme CSS files are not bundled in this package — they're provided by `@marianmeres/design-tokens/css/*.css` (42 themes) and imported by `src/lib/index.css`.
35
+
35
36
  ---
36
37
 
37
38
  ## Critical Conventions
@@ -116,10 +117,10 @@ Global tokens that control cross-component visual properties. Defined in `src/li
116
117
 
117
118
  ### Domain Docs
118
119
 
119
- - [Components](./docs/domains/components.md) — 56 component directories, Props pattern, snippets
120
+ - [Components](./docs/domains/components.md) — 57 component directories, Props pattern, snippets
120
121
  - [Theming](./docs/domains/theming.md) — CSS tokens, dark mode, themes
121
122
  - [Actions](./docs/domains/actions.md) — 15 Svelte directives
122
- - [Utils](./docs/domains/utils.md) — 43 utility modules
123
+ - [Utils](./docs/domains/utils.md) — 44 utility modules
123
124
 
124
125
  ### Reference
125
126
 
package/API.md CHANGED
@@ -132,20 +132,41 @@ Navigation wrapper component.
132
132
 
133
133
  #### `Header`
134
134
 
135
- Responsive navigation header with logo, nav items, avatar, and automatic hamburger collapse. Renders as `<header>`.
136
-
137
- | Prop | Type | Default | Description |
138
- | ------------------- | ----------------- | --------------------- | ---------------------------------- |
139
- | `logo` | `Snippet` | — | Logo/brand snippet |
140
- | `projectName` | `string` | | Simple text logo alternative |
141
- | `items` | `HeaderNavItem[]` | `[]` | Navigation items |
142
- | `avatar` | `Snippet` | | Avatar snippet (far right) |
143
- | `avatarOnClick` | `() => void` | — | Avatar click handler |
144
- | `collapseThreshold` | `number` | `768` | Width (px) to collapse; 0 disables |
145
- | `fixed` | `boolean` | `false` | Fixed positioning at top |
146
- | `isCollapsed` | `boolean` | | Bindable: collapsed state |
147
- | `isMenuOpen` | `boolean` | | Bindable: hamburger menu open |
148
- | `onSelect` | `(item) => void` | — | Item selection callback |
135
+ Responsive navigation header with leading slot, logo, nav items, locale switcher, action icon buttons, avatar, and configurable responsive collapse (`"hamburger"` fold or `"hide"` for app-like shells). Renders as `<header>`.
136
+
137
+ | Prop | Type | Default | Description |
138
+ | ----------------------- | ----------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
139
+ | `leading` | `Snippet<[{ isCollapsed }]>` | — | Leading (left-side) slot. Overrides `leadingHamburger`. |
140
+ | `leadingHamburger` | `boolean \| "collapsed"` | `false` | Built-in hamburger in the leading slot (`"collapsed"` = only below threshold). Ignored when `leading` is provided. |
141
+ | `onLeadingHamburger` | `() => void` | — | Click handler for the built-in leading hamburger (typically opens a drawer). |
142
+ | `leadingHamburgerIcon` | `THC` | menu icon | Icon override for the built-in leading hamburger. |
143
+ | `leadingHamburgerLabel` | `string` | `"Open menu"` | Aria-label for the leading hamburger. |
144
+ | `logo` | `Snippet` | | Logo/brand snippet. |
145
+ | `projectName` | `string` | | Simple text logo alternative. |
146
+ | `navVariant` | `ButtonVariant` | `"ghost"` | Button variant for nav items and the locale switcher trigger. |
147
+ | `items` | `HeaderNavItem[]` | `[]` | Navigation items — inline when expanded, dropdown when collapsed (hamburger mode). |
148
+ | `actions` | `HeaderActionItem[]` | `[]` | Action icon buttons between the locale switcher and the avatar. Always visible never fold into the dropdown. |
149
+ | `onActionSelect` | `(action) => void` | — | Called after the per-item `onclick`. |
150
+ | `avatar` | `Snippet` | — | Avatar snippet (far right). |
151
+ | `avatarOnClick` | `() => void` | — | Makes the avatar interactive. In `"hamburger"` collapse mode it moves into the dropdown. |
152
+ | `avatarLabel` | `THC` | `"Account"` | Label for the avatar entry inside the collapsed dropdown. |
153
+ | `locales` | `HeaderLocaleItem[]` | `[]` | Locale items. Switcher only renders when 2+. |
154
+ | `activeLocale` | `string` | — | Current locale id. |
155
+ | `onLocaleChange` | `(localeId) => void` | — | Locale selection callback. |
156
+ | `localeLabel` | `THC` | `"Language"` | Section header inside the collapsed dropdown. |
157
+ | `contentMaxWidth` | `string \| number` | — | Max-width of the inner content row (outer header stays 100%). Accepts any CSS length. Maps to `--stuic-header-content-max-width`. |
158
+ | `collapseThreshold` | `number` | `768` | Width (px) to collapse; 0 disables. |
159
+ | `collapseMode` | `"hamburger" \| "hide"` | `"hamburger"` | Collapse behavior. `"hide"` keeps avatar/actions visible and renders no trailing hamburger (app-shell pattern). |
160
+ | `keepLocaleOnCollapse` | `boolean` | `false` | Keep the locale switcher visible in collapsed mode (only `collapseMode === "hide"`). |
161
+ | `fixed` | `boolean` | `false` | Fixed positioning at the top. |
162
+ | `isCollapsed` | `boolean` | — | Bindable: collapsed state. |
163
+ | `isMenuOpen` | `boolean` | — | Bindable: hamburger menu open. |
164
+ | `dropdownPosition` | `DropdownMenuPosition` | `"bottom-span-right"` | Position of the collapsed dropdown. |
165
+ | `iconSize` | `number` | `24` | Hamburger/X icon size in px. |
166
+ | `onSelect` | `(item) => void` | — | Item selection callback (both modes). |
167
+ | `children` | `Snippet<[{ isCollapsed, items, offsetWidth }]>` | — | Escape hatch: override the entire inner layout. |
168
+
169
+ Class slots: `class`, `classContent`, `classLeading`, `classLeadingHamburger`, `classLogo`, `classNav`, `classNavItem`, `classNavItemActive`, `classActions`, `classAction`, `classActionActive`, `classEnd`, `classAvatar`, `classLocale`, `classHamburger`, `classDropdown`.
149
170
 
150
171
  ```svelte
151
172
  <Header
@@ -159,11 +180,32 @@ Responsive navigation header with logo, nav items, avatar, and automatic hamburg
159
180
  <Avatar src="/me.jpg" alt="User" />
160
181
  {/snippet}
161
182
  </Header>
183
+
184
+ <!-- App-shell pattern: avatar + actions stay visible in collapsed mode,
185
+ nav items hide, leading hamburger opens a drawer. -->
186
+ <Header
187
+ projectName="App"
188
+ items={navItems}
189
+ actions={[
190
+ { id: "search", icon: { html: iconSearch() }, label: "Search", onclick: openSearch },
191
+ { id: "cart", icon: { html: iconCart() }, label: "Cart", onclick: openCart },
192
+ ]}
193
+ collapseMode="hide"
194
+ leadingHamburger="collapsed"
195
+ onLeadingHamburger={() => (drawerOpen = true)}
196
+ avatarOnClick={() => goto("/me")}
197
+ >
198
+ {#snippet avatar()}<Avatar initials="MM" autoColor />{/snippet}
199
+ </Header>
162
200
  ```
163
201
 
164
- **HeaderNavItem:** `{ id, label, href?, onclick?, icon?, active?, disabled?, class? }`
202
+ **HeaderNavItem:** `{ id, label, href?, target?, onclick?, icon?, active?, disabled?, class? }`
203
+
204
+ **HeaderActionItem:** `{ id, icon?, label, onclick?, href?, target?, active?, disabled?, class?, render? }` — `render` is an optional `Snippet<[{ action, class, isCollapsed, onclick }]>` that replaces the default `<Button>` for that action (useful for wrapping in a popover/tooltip directive or adding a count badge while keeping default positioning).
165
205
 
166
- CSS tokens: `--stuic-header-padding-x`, `--stuic-header-padding-y`, `--stuic-header-gap`, `--stuic-header-min-height`, `--stuic-header-nav-gap`, `--stuic-header-bg`, `--stuic-header-text`, `--stuic-header-border-width`, `--stuic-header-border-color`, `--stuic-header-nav-item-bg-active`, `--stuic-header-nav-item-text-active`, `--stuic-header-z-index`.
206
+ **HeaderLocaleItem:** `{ id, label }`
207
+
208
+ CSS tokens: `--stuic-header-padding-x`, `--stuic-header-padding-y`, `--stuic-header-gap`, `--stuic-header-min-height`, `--stuic-header-nav-gap`, `--stuic-header-content-max-width`, `--stuic-header-bg`, `--stuic-header-text`, `--stuic-header-border-width`, `--stuic-header-border-color`, `--stuic-header-nav-item-bg-active`, `--stuic-header-nav-item-text-active`, `--stuic-header-z-index`.
167
209
 
168
210
  ---
169
211
 
@@ -334,6 +376,38 @@ International phone number input with country dial code picker. Parses and compo
334
376
 
335
377
  Exports: `FieldPhoneNumber`, `FieldPhoneNumberProps`, `validatePhoneNumber`, `Country`.
336
378
 
379
+ #### `FieldCountry`
380
+
381
+ Country picker dropdown with searchable, optionally flag-prefixed list. Submits a country ISO alpha-2 code via a hidden input. Pairs naturally with `FieldPhoneNumber` and with checkout/address forms.
382
+
383
+ | Prop | Type | Default | Description |
384
+ | -------------------- | --------------------------------- | ----------- | -------------------------------------------------------------------------- |
385
+ | `value` | `string` | `""` | Bindable ISO alpha-2 code (e.g. `"SK"`). Empty = unselected. |
386
+ | `onChange` | `(iso: string) => void` | — | Called when selection changes. |
387
+ | `countryList` | `Country[] \| string[]` | all | Restrict the list to specific countries (objects or ISO codes). |
388
+ | `preferredCountries` | `string[]` | — | ISO codes pinned at the top of the dropdown. |
389
+ | `countryNames` | `Record<string, string>` | English | Override displayed country names (keyed by ISO code). |
390
+ | `flags` | `boolean` | `true` | Show country flag emoji. |
391
+ | `name` | `string` | — | Hidden input name (enables form submission + native validation). |
392
+ | `placeholder` | `string` | — | Trigger placeholder text when nothing is selected. |
393
+ | `required` | `boolean` | `false` | Required indicator + validation. |
394
+ | `disabled` | `boolean` | `false` | Disable the trigger and dropdown. |
395
+ | `validate` | `boolean \| ValidateOptions` | enabled | Validation behavior (default-on, see Imperative validate API). |
396
+
397
+ Integrates with `InputWrap` — supports `label`, `description`, `renderSize`, `labelLeft`, `labelLeftWidth`, `labelLeftBreakpoint`, `inputBefore`, `inputAfter`, `inputBelow`, `below`, `classInput`, `classDropdown`.
398
+
399
+ ```svelte
400
+ <FieldCountry
401
+ bind:value={address.country}
402
+ name="country"
403
+ label="Country"
404
+ preferredCountries={["SK", "CZ", "AT", "DE"]}
405
+ required
406
+ />
407
+ ```
408
+
409
+ Exports: `FieldCountry`, `FieldCountryProps`, `Country`, `COUNTRIES`, `ISO_MAP`.
410
+
337
411
  #### `FieldObject`
338
412
 
339
413
  Dual-mode JSON object editor with pretty-print and raw edit modes. Validates JSON syntax, supports recursive depth display, auto-grow textarea, and form submission via hidden input.
package/README.md CHANGED
@@ -148,7 +148,7 @@ AppShell, Accordion, Backdrop, Modal, ModalDialog, Drawer, Collapsible, Header,
148
148
 
149
149
  ### Forms & Inputs
150
150
 
151
- FieldInput, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldOptions, FieldKeyValues, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
151
+ FieldInput, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldOptions, FieldKeyValues, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, FieldCountry, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, RegisterFormModal, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
152
152
 
153
153
  ### Buttons & Controls
154
154
 
@@ -37,17 +37,95 @@
37
37
  label: THC;
38
38
  }
39
39
 
40
+ export interface HeaderActionItem {
41
+ /** Unique identifier */
42
+ id: string | number;
43
+ /** Icon — THC (string/html/component/snippet). The visible content.
44
+ * Required for the default rendering; ignored when `render` is provided
45
+ * (the snippet owns its own DOM). */
46
+ icon?: THC;
47
+ /** Accessible label (aria-label). */
48
+ label: THC;
49
+ /** Click handler */
50
+ onclick?: () => void;
51
+ /** Render as a link instead of a button */
52
+ href?: string;
53
+ /** Link target (e.g., "_blank"). Only relevant when href is set. */
54
+ target?: string;
55
+ /** Active state styling (e.g., when a panel triggered by this action is open) */
56
+ active?: boolean;
57
+ /** Whether this action is disabled */
58
+ disabled?: boolean;
59
+ /** Additional CSS classes */
60
+ class?: string;
61
+ /** Optional custom renderer. When provided, replaces the default
62
+ * `<Button>` rendering for this action. The Header still owns
63
+ * positioning (slot in the actions row) and the collapse decision.
64
+ *
65
+ * Use this when an action needs custom DOM around its trigger —
66
+ * e.g. a popover/tooltip directive, or a count/dot badge overlay.
67
+ *
68
+ * Snippet args:
69
+ * - `action` — the item itself (lets a snippet be reused across items)
70
+ * - `class` — the same merged class the default `<Button>` would receive,
71
+ * so consumers can opt into the default look
72
+ * - `isCollapsed`— current collapse state
73
+ * - `onclick` — pre-wired handler that calls `action.onclick` and
74
+ * `onActionSelect`; consumers can wire it to their
75
+ * button or ignore it. */
76
+ render?: Snippet<
77
+ [
78
+ {
79
+ action: HeaderActionItem;
80
+ class: string;
81
+ isCollapsed: boolean;
82
+ onclick: () => void;
83
+ },
84
+ ]
85
+ >;
86
+ }
87
+
88
+ /** Collapse behavior when the header drops below `collapseThreshold`:
89
+ * - "hamburger": nav items fold into a trailing dropdown along with the
90
+ * locale switcher and an interactive avatar.
91
+ * - "hide": nav items are hidden entirely. No trailing hamburger renders.
92
+ * Avatar stays visible. Locale visibility is controlled by
93
+ * `keepLocaleOnCollapse`. */
94
+ export type HeaderCollapseMode = "hamburger" | "hide";
95
+
96
+ /** Visibility for the built-in leading hamburger button:
97
+ * - false/undefined: not rendered
98
+ * - true: always rendered
99
+ * - "collapsed": only rendered when the header is below the collapse threshold */
100
+ export type HeaderLeadingHamburger = boolean | "collapsed";
101
+
40
102
  export interface Props extends Omit<HTMLAttributes<HTMLElement>, "children"> {
103
+ /** Leading (left-side) slot. Renders before the logo/title.
104
+ * Use for a hamburger button, back arrow, breadcrumbs, etc.
105
+ * When provided, overrides the built-in `leadingHamburger`. */
106
+ leading?: Snippet<[{ isCollapsed: boolean }]>;
107
+ /** Convenience: render a built-in hamburger button in the leading slot.
108
+ * Ignored when the `leading` snippet is provided. */
109
+ leadingHamburger?: HeaderLeadingHamburger;
110
+ /** Click handler for the built-in leading hamburger (typically opens a drawer) */
111
+ onLeadingHamburger?: () => void;
112
+ /** Icon for the built-in leading hamburger (defaults to a menu icon) */
113
+ leadingHamburgerIcon?: THC;
114
+ /** Aria-label for the built-in leading hamburger (defaults to "Open menu") */
115
+ leadingHamburgerLabel?: string;
41
116
  /** Logo/brand snippet — full control over the left branding area */
42
117
  logo?: Snippet;
43
- /** Horizontal alignment of the nav items in expanded mode */
44
- navAlign?: "left" | "right";
45
118
  /** Button variant for nav items and locale trigger (defaults to "ghost") */
46
119
  navVariant?: ButtonVariant;
47
120
  /** Simple text alternative to the logo snippet */
48
121
  projectName?: string;
49
122
  /** Navigation items — inline when expanded, DropdownMenu when collapsed */
50
123
  items?: HeaderNavItem[];
124
+ /** Action icon buttons displayed between the locale switcher and the avatar.
125
+ * Always visible — they do not fold into the trailing dropdown. */
126
+ actions?: HeaderActionItem[];
127
+ /** Called when an action is selected (in addition to the per-item onclick) */
128
+ onActionSelect?: (action: HeaderActionItem) => void;
51
129
  /** Avatar/user snippet — rendered at the far right */
52
130
  avatar?: Snippet;
53
131
  /** When provided, makes the avatar interactive. In expanded mode wraps it in a
@@ -63,8 +141,21 @@
63
141
  onLocaleChange?: (localeId: string) => void;
64
142
  /** Section header label for locales in collapsed dropdown (defaults to "Language") */
65
143
  localeLabel?: THC;
144
+ /** Max-width of the inner content row. The outer `<header>` stays 100%
145
+ * wide (background fills the parent); the inner content is centered and
146
+ * capped at this value. Accepts any CSS length: "1024px", "72rem",
147
+ * "100%", "none". Default: undefined → unbounded.
148
+ * Equivalent global override:
149
+ * `:root { --stuic-header-content-max-width: 72rem; }` */
150
+ contentMaxWidth?: string | number;
66
151
  /** Element width (px) below which nav collapses to hamburger. 0 to disable. */
67
152
  collapseThreshold?: number;
153
+ /** Collapse behavior when below threshold (defaults to "hamburger") */
154
+ collapseMode?: HeaderCollapseMode;
155
+ /** When `collapseMode === "hide"`, keep the locale switcher visible in
156
+ * collapsed mode. No effect when `collapseMode === "hamburger"`
157
+ * (locale already folds into the trailing dropdown there). */
158
+ keepLocaleOnCollapse?: boolean;
68
159
  /** Fixed positioning (top of viewport) */
69
160
  fixed?: boolean;
70
161
  /** Bindable: whether the header is currently in collapsed (hamburger) mode */
@@ -81,6 +172,12 @@
81
172
  unstyled?: boolean;
82
173
  /** Additional CSS classes for the root <header> */
83
174
  class?: string;
175
+ /** Classes for the inner content wrapper */
176
+ classContent?: string;
177
+ /** Classes for the leading area */
178
+ classLeading?: string;
179
+ /** Classes for the built-in leading hamburger button */
180
+ classLeadingHamburger?: string;
84
181
  /** Classes for the logo area */
85
182
  classLogo?: string;
86
183
  /** Classes for the nav area (expanded mode) */
@@ -89,13 +186,19 @@
89
186
  classNavItem?: string;
90
187
  /** Classes for active nav items */
91
188
  classNavItemActive?: string;
92
- /** Classes for the end area (avatar + hamburger) */
189
+ /** Classes for the actions wrapper */
190
+ classActions?: string;
191
+ /** Classes for individual action buttons */
192
+ classAction?: string;
193
+ /** Classes for active action buttons */
194
+ classActionActive?: string;
195
+ /** Classes for the end area (locale + avatar + trailing hamburger) */
93
196
  classEnd?: string;
94
197
  /** Classes for the avatar container */
95
198
  classAvatar?: string;
96
199
  /** Classes for the locale switcher trigger (expanded mode) */
97
200
  classLocale?: string;
98
- /** Classes for the hamburger button */
201
+ /** Classes for the trailing (right-side) hamburger button */
99
202
  classHamburger?: string;
100
203
  /** Classes for the dropdown wrapper (collapsed mode) */
101
204
  classDropdown?: string;
@@ -108,9 +211,14 @@
108
211
  }
109
212
 
110
213
  export const HEADER_BASE_CLASSES = "stuic-header";
214
+ export const HEADER_CONTENT_CLASSES = "stuic-header-content";
215
+ export const HEADER_LEADING_CLASSES = "stuic-header-leading";
216
+ export const HEADER_LEADING_HAMBURGER_CLASSES = "stuic-header-leading-hamburger";
111
217
  export const HEADER_LOGO_CLASSES = "stuic-header-logo";
112
218
  export const HEADER_NAV_CLASSES = "stuic-header-nav";
113
219
  export const HEADER_NAV_ITEM_CLASSES = "stuic-header-nav-item";
220
+ export const HEADER_ACTIONS_CLASSES = "stuic-header-actions";
221
+ export const HEADER_ACTION_CLASSES = "stuic-header-action";
114
222
  export const HEADER_END_CLASSES = "stuic-header-end";
115
223
  export const HEADER_HAMBURGER_CLASSES = "stuic-header-hamburger";
116
224
  export const HEADER_LOCALE_CLASSES = "stuic-header-locale";
@@ -125,11 +233,17 @@
125
233
  import IconSwap from "../IconSwap/IconSwap.svelte";
126
234
 
127
235
  let {
236
+ leading,
237
+ leadingHamburger = false,
238
+ onLeadingHamburger,
239
+ leadingHamburgerIcon,
240
+ leadingHamburgerLabel = "Open menu",
128
241
  logo,
129
242
  projectName,
130
- navAlign = "right",
131
243
  navVariant = "ghost",
132
244
  items = [],
245
+ actions = [],
246
+ onActionSelect,
133
247
  avatar,
134
248
  avatarOnClick,
135
249
  avatarLabel = "Account",
@@ -137,7 +251,10 @@
137
251
  activeLocale,
138
252
  onLocaleChange,
139
253
  localeLabel = "Language",
254
+ contentMaxWidth,
140
255
  collapseThreshold = 768,
256
+ collapseMode = "hamburger",
257
+ keepLocaleOnCollapse = false,
141
258
  fixed = false,
142
259
  isCollapsed = $bindable(false),
143
260
  isMenuOpen = $bindable(false),
@@ -146,10 +263,16 @@
146
263
  onSelect,
147
264
  unstyled = false,
148
265
  class: classProp,
266
+ classContent,
267
+ classLeading,
268
+ classLeadingHamburger,
149
269
  classLogo,
150
270
  classNav,
151
271
  classNavItem,
152
272
  classNavItemActive,
273
+ classActions,
274
+ classAction,
275
+ classActionActive,
153
276
  classEnd,
154
277
  classAvatar,
155
278
  classLocale,
@@ -160,13 +283,19 @@
160
283
  ...rest
161
284
  }: Props = $props();
162
285
 
163
- // Width measurement (same pattern as Card)
164
- let _offsetWidth = $state(0);
286
+ // Width measurement. We bind both outer and inner because:
287
+ // - Default layout renders an inner wrapper; the inner row width is what
288
+ // actually determines whether nav items fit, so collapse should key off it.
289
+ // - With the `children` escape hatch there is no inner wrapper, so we
290
+ // fall back to the outer measurement.
291
+ let _outerWidth = $state(0);
292
+ let _innerWidth = $state(0);
293
+ let _measuredWidth = $derived(_innerWidth || _outerWidth);
165
294
 
166
295
  // Collapsed state based on threshold
167
296
  let _isCollapsed = $derived.by(() => {
168
297
  if (!collapseThreshold) return false;
169
- return _offsetWidth > 0 && _offsetWidth < collapseThreshold;
298
+ return _measuredWidth > 0 && _measuredWidth < collapseThreshold;
170
299
  });
171
300
 
172
301
  // Sync bindable
@@ -181,12 +310,31 @@
181
310
  }
182
311
  });
183
312
 
184
- // Whether the avatar moves into the dropdown when collapsed
185
- let _avatarInDropdown = $derived(!!(avatar && avatarOnClick));
313
+ // Whether the avatar moves into the dropdown when collapsed.
314
+ // In "hide" mode the avatar always stays visible (never folds).
315
+ let _avatarInDropdown = $derived(
316
+ collapseMode === "hamburger" && !!(avatar && avatarOnClick)
317
+ );
318
+
319
+ // Whether to render the built-in leading hamburger (ignored when `leading` snippet is set)
320
+ let _showLeadingHamburger = $derived.by(() => {
321
+ if (leading) return false;
322
+ if (leadingHamburger === "collapsed") return _isCollapsed;
323
+ return !!leadingHamburger;
324
+ });
186
325
 
187
326
  // Locale switcher: only render when 2+ locales
188
327
  let _hasLocales = $derived(locales.length > 1);
189
328
 
329
+ // Visibility of the inline (expanded-form) locale switcher.
330
+ // In "hamburger" mode: visible only when not collapsed (it folds into the
331
+ // trailing dropdown when collapsed). In "hide" mode: visible when not
332
+ // collapsed, or when collapsed and `keepLocaleOnCollapse` is set.
333
+ let _showLocaleSwitcher = $derived(
334
+ _hasLocales &&
335
+ (!_isCollapsed || (collapseMode === "hide" && keepLocaleOnCollapse))
336
+ );
337
+
190
338
  // Active locale object (for trigger label); fallback to first
191
339
  let _activeLocale = $derived(locales.find((l) => l.id === activeLocale) ?? locales[0]);
192
340
 
@@ -209,8 +357,11 @@
209
357
  );
210
358
  });
211
359
 
212
- // Map HeaderNavItem[] to DropdownMenuItem[] for collapsed mode
360
+ // Map HeaderNavItem[] to DropdownMenuItem[] for collapsed mode.
361
+ // In "hide" mode the trailing hamburger is suppressed entirely — return
362
+ // an empty list so no dropdown trigger renders.
213
363
  let _dropdownItems = $derived.by((): DropdownMenuItem[] => {
364
+ if (collapseMode === "hide") return [];
214
365
  const navItems: DropdownMenuItem[] = items.map(
215
366
  (item) =>
216
367
  ({
@@ -269,10 +420,25 @@
269
420
 
270
421
  // CSS classes
271
422
  let _class = $derived(unstyled ? classProp : twMerge(HEADER_BASE_CLASSES, classProp));
423
+ let _classContent = $derived(
424
+ unstyled ? classContent : twMerge(HEADER_CONTENT_CLASSES, classContent)
425
+ );
426
+ let _styleContent = $derived.by(() => {
427
+ if (contentMaxWidth == null) return undefined;
428
+ const value =
429
+ typeof contentMaxWidth === "number" ? `${contentMaxWidth}px` : contentMaxWidth;
430
+ return `--stuic-header-content-max-width: ${value}`;
431
+ });
432
+ let _classLeading = $derived(
433
+ unstyled ? classLeading : twMerge(HEADER_LEADING_CLASSES, classLeading)
434
+ );
272
435
  let _classLogo = $derived(
273
436
  unstyled ? classLogo : twMerge(HEADER_LOGO_CLASSES, classLogo)
274
437
  );
275
438
  let _classNav = $derived(unstyled ? classNav : twMerge(HEADER_NAV_CLASSES, classNav));
439
+ let _classActions = $derived(
440
+ unstyled ? classActions : twMerge(HEADER_ACTIONS_CLASSES, classActions)
441
+ );
276
442
  let _classEnd = $derived(unstyled ? classEnd : twMerge(HEADER_END_CLASSES, classEnd));
277
443
  let _classLocale = $derived(
278
444
  unstyled ? classLocale : twMerge(HEADER_LOCALE_CLASSES, classLocale)
@@ -283,11 +449,17 @@
283
449
  item.onclick?.();
284
450
  onSelect?.(item);
285
451
  }
452
+
453
+ function handleActionClick(action: HeaderActionItem) {
454
+ if (action.disabled) return;
455
+ action.onclick?.();
456
+ onActionSelect?.(action);
457
+ }
286
458
  </script>
287
459
 
288
460
  <header
289
461
  bind:this={el}
290
- bind:offsetWidth={_offsetWidth}
462
+ bind:offsetWidth={_outerWidth}
291
463
  class={_class}
292
464
  data-fixed={!unstyled && fixed ? "" : undefined}
293
465
  data-collapsed={!unstyled && _isCollapsed ? "" : undefined}
@@ -297,26 +469,48 @@
297
469
  {@render children({
298
470
  isCollapsed: _isCollapsed,
299
471
  items,
300
- offsetWidth: _offsetWidth,
472
+ offsetWidth: _measuredWidth,
301
473
  })}
302
474
  {:else}
303
- <!-- Logo / Brand -->
304
- {#if logo || projectName}
305
- <div class={_classLogo}>
306
- {#if logo}
307
- {@render logo()}
308
- {:else if projectName}
309
- <span class={unstyled ? undefined : "stuic-header-project-name"}>
310
- {projectName}
311
- </span>
312
- {/if}
475
+ <div bind:offsetWidth={_innerWidth} class={_classContent} style={_styleContent}>
476
+ <!-- Leading slot (left-side) -->
477
+ {#if leading}
478
+ <div class={_classLeading}>
479
+ {@render leading({ isCollapsed: _isCollapsed })}
480
+ </div>
481
+ {:else if _showLeadingHamburger}
482
+ <div class={_classLeading}>
483
+ <Button
484
+ variant="ghost"
485
+ iconButton
486
+ size="sm"
487
+ {unstyled}
488
+ class={twMerge(
489
+ !unstyled && HEADER_LEADING_HAMBURGER_CLASSES,
490
+ classLeadingHamburger
491
+ )}
492
+ onclick={onLeadingHamburger}
493
+ aria-label={leadingHamburgerLabel}
494
+ >
495
+ {#if leadingHamburgerIcon}
496
+ <Thc thc={leadingHamburgerIcon} />
497
+ {:else}
498
+ {@html iconMenu({ size: iconSize })}
499
+ {/if}
500
+ </Button>
313
501
  </div>
314
502
  {/if}
315
503
 
316
- <!-- Spacer (before nav when right-aligned) -->
317
- {#if navAlign !== "left"}
318
- <div class={unstyled ? undefined : "stuic-header-spacer"}></div>
319
- {/if}
504
+ <!-- Logo / Title (flex-1) -->
505
+ <div class={_classLogo}>
506
+ {#if logo}
507
+ {@render logo()}
508
+ {:else if projectName}
509
+ <span class={unstyled ? undefined : "stuic-header-project-name"}>
510
+ {projectName}
511
+ </span>
512
+ {/if}
513
+ </div>
320
514
 
321
515
  <!-- Nav items (expanded mode) -->
322
516
  {#if !_isCollapsed && items.length > 0}
@@ -350,15 +544,10 @@
350
544
  </nav>
351
545
  {/if}
352
546
 
353
- <!-- Spacer (after nav when left-aligned) -->
354
- {#if navAlign === "left"}
355
- <div class={unstyled ? undefined : "stuic-header-spacer"}></div>
356
- {/if}
357
-
358
- <!-- End area: locale + avatar + hamburger -->
547
+ <!-- End area: locale + actions + avatar + trailing hamburger -->
359
548
  <div class={_classEnd}>
360
- <!-- Locale switcher (expanded mode only) -->
361
- {#if !_isCollapsed && _hasLocales}
549
+ <!-- Locale switcher (shown when expanded, or in "hide" mode with keepLocaleOnCollapse) -->
550
+ {#if _showLocaleSwitcher}
362
551
  <DropdownMenu
363
552
  items={_localeDropdownItems}
364
553
  position="bottom-span-right"
@@ -393,6 +582,46 @@
393
582
  </DropdownMenu>
394
583
  {/if}
395
584
 
585
+ <!-- Actions (icon buttons, always visible) -->
586
+ {#if actions.length > 0}
587
+ <div class={_classActions}>
588
+ {#each actions as action (action.id)}
589
+ {@const actionClass = twMerge(
590
+ !unstyled && HEADER_ACTION_CLASSES,
591
+ !unstyled && action.active && classActionActive,
592
+ classAction,
593
+ action.class
594
+ )}
595
+ {#if action.render}
596
+ {@render action.render({
597
+ action,
598
+ class: actionClass,
599
+ isCollapsed: _isCollapsed,
600
+ onclick: () => handleActionClick(action),
601
+ })}
602
+ {:else}
603
+ <Button
604
+ variant="ghost"
605
+ iconButton
606
+ size="sm"
607
+ href={action.href}
608
+ target={action.target}
609
+ disabled={action.disabled}
610
+ {unstyled}
611
+ class={actionClass}
612
+ data-active={!unstyled && action.active ? "" : undefined}
613
+ aria-label={typeof action.label === "string" ? action.label : undefined}
614
+ onclick={() => handleActionClick(action)}
615
+ >
616
+ {#if action.icon !== undefined}
617
+ <Thc thc={action.icon} />
618
+ {/if}
619
+ </Button>
620
+ {/if}
621
+ {/each}
622
+ </div>
623
+ {/if}
624
+
396
625
  <!-- Avatar: hidden when collapsed + avatarOnClick (moves into dropdown) -->
397
626
  {#if avatar && !(_isCollapsed && _avatarInDropdown)}
398
627
  {#if avatarOnClick}
@@ -437,5 +666,6 @@
437
666
  </DropdownMenu>
438
667
  {/if}
439
668
  </div>
669
+ </div>
440
670
  {/if}
441
671
  </header>