@marianmeres/stuic 3.75.0 → 3.76.1

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.
@@ -142,6 +142,7 @@
142
142
  data-rounded-full={!unstyled && roundedFull ? "true" : undefined}
143
143
  data-aspect1={!unstyled && _isAspect1 ? "true" : undefined}
144
144
  data-icon-button={!unstyled && _isIconButton ? "true" : undefined}
145
+ data-x={!unstyled && !!_xProps ? "true" : undefined}
145
146
  use:tooltip={_tooltipConfig}
146
147
  {...rest as HTMLAnchorAttributes}
147
148
  >
@@ -175,6 +176,7 @@
175
176
  data-rounded-full={!unstyled && roundedFull ? "true" : undefined}
176
177
  data-aspect1={!unstyled && _isAspect1 ? "true" : undefined}
177
178
  data-icon-button={!unstyled && _isIconButton ? "true" : undefined}
179
+ data-x={!unstyled && !!_xProps ? "true" : undefined}
178
180
  use:tooltip={_tooltipConfig}
179
181
  {...rest}
180
182
  >
@@ -42,6 +42,14 @@
42
42
  /* Raised (3D push) effect */
43
43
  --stuic-button-raised-offset: 2px;
44
44
  --stuic-button-raised-color: rgb(0 0 0 / 0.8);
45
+
46
+ /* Neutral overlay hover for "pure rounded X" (ghost + x + roundedFull).
47
+ Intentionally non-themed — sits on top of any background color. */
48
+ --stuic-button-x-bg-hover: rgb(0 0 0 / 0.1);
49
+ }
50
+
51
+ :root.dark {
52
+ --stuic-button-x-bg-hover: rgb(255 255 255 / 0.05);
45
53
  }
46
54
 
47
55
  @layer components {
@@ -416,4 +424,14 @@
416
424
  .stuic-button[data-icon-button] {
417
425
  --stuic-button-radius: 9999px;
418
426
  }
427
+
428
+ /* ============================================================================
429
+ PURE ROUNDED X
430
+ Ghost variant + x icon + fully rounded — replace the intent-tinted ghost
431
+ hover with a neutral overlay so the button reads on any background.
432
+ ============================================================================ */
433
+ .stuic-button[data-variant="ghost"][data-x][data-rounded-full] {
434
+ --_bg-hover: var(--stuic-button-x-bg-hover);
435
+ --_bg-active: var(--stuic-button-x-bg-hover);
436
+ }
419
437
  }
@@ -18,6 +18,7 @@
18
18
  </script>
19
19
 
20
20
  <script lang="ts">
21
+ import { untrack } from "svelte";
21
22
  import { slide } from "svelte/transition";
22
23
  import { twMerge } from "../../utils/tw-merge.js";
23
24
  import Thc, { isTHCNotEmpty } from "../Thc/Thc.svelte";
@@ -44,13 +45,36 @@
44
45
  intent,
45
46
  forceAsHtml = true,
46
47
  duration = 150,
47
- onDismiss = () => (message = ""),
48
+ onDismiss,
48
49
  withIcon,
49
50
  iconFn,
50
51
  }: Props = $props();
51
52
 
53
+ // Track dismissal in local state instead of mutating the (non-bindable) `message`
54
+ // prop. Mutating a destructured prop var creates a local shadow that Svelte 5
55
+ // won't always overwrite when the parent re-passes the same value — so a user
56
+ // who dismissed an error would never see the SAME error message again, even
57
+ // after the parent re-set it. Keeping `_dismissed` separate sidesteps that and
58
+ // makes the dismiss state reset cleanly whenever the message changes.
52
59
  let _message = $derived(message ? String(message) : "");
53
- let _show = $derived(isTHCNotEmpty(_message));
60
+ let _dismissed = $state(false);
61
+ let _show = $derived(isTHCNotEmpty(_message) && !_dismissed);
62
+
63
+ // Reset the dismissed flag whenever the message changes — a new (or re-set)
64
+ // message from the parent should re-show, even if the user previously dismissed.
65
+ $effect(() => {
66
+ void _message;
67
+ untrack(() => {
68
+ if (_dismissed) _dismissed = false;
69
+ });
70
+ });
71
+
72
+ // Default dismiss handler hides the message locally. Parent state is left alone
73
+ // (this prop isn't bindable). Consumers wanting parent-side cleanup pass their own.
74
+ // `null`/`false` mean "no dismiss button" and are handled at the render site.
75
+ let _onDismiss = $derived(
76
+ typeof onDismiss === "function" ? onDismiss : () => (_dismissed = true)
77
+ );
54
78
 
55
79
  let _iconHtml = $derived.by(() => {
56
80
  if (iconFn === false) return "";
@@ -77,7 +101,7 @@
77
101
  <Thc thc={_message} {forceAsHtml} />
78
102
  </div>
79
103
 
80
- {#if typeof onDismiss === "function"}
104
+ {#if onDismiss !== false && onDismiss !== null}
81
105
  <div class="dismiss">
82
106
  <Button
83
107
  x
@@ -86,7 +110,7 @@
86
110
  roundedFull
87
111
  size="sm"
88
112
  type="button"
89
- onclick={() => onDismiss()}
113
+ onclick={() => _onDismiss()}
90
114
  />
91
115
  </div>
92
116
  {/if}
@@ -8,11 +8,6 @@
8
8
  --stuic-dismissible-message-padding-x: calc(var(--spacing) * 4);
9
9
  --stuic-dismissible-message-padding-y: calc(var(--spacing) * 3);
10
10
  --stuic-dismissible-x-padding: calc(var(--spacing) * 1);
11
- --stuic-dismissible-x-bg-hover: rgb(0 0 0 / 0.1);
12
- }
13
-
14
- :root.dark {
15
- --stuic-dismissible-x-bg-hover: rgb(255 255 255 / 0.05);
16
11
  }
17
12
 
18
13
  @layer components {
@@ -73,8 +68,6 @@
73
68
  /* Dismiss button inherits message text color */
74
69
  .stuic-dismissible-message > .dismiss .stuic-button {
75
70
  color: var(--_text);
76
- --_bg-hover: var(--stuic-dismissible-x-bg-hover);
77
- --_bg-active: var(--stuic-dismissible-x-bg-hover);
78
71
  }
79
72
 
80
73
  /* =============================================================================
@@ -77,6 +77,7 @@
77
77
  </script>
78
78
 
79
79
  <script lang="ts">
80
+ import { untrack } from "svelte";
80
81
  import { twMerge } from "../../utils/tw-merge.js";
81
82
  import { t_default } from "./_internal/login-form-i18n-defaults.js";
82
83
  import {
@@ -130,6 +131,19 @@
130
131
  }
131
132
 
132
133
  function handleSubmitValid() {
134
+ // Defensively clear any stale customValidity left on form fields by a prior
135
+ // validation pass. The `validate` action already clears it when the field's
136
+ // customValidator returns an empty string, but a field can skip that path
137
+ // (e.g., it was disabled, or its $effect torn down between submits) and the
138
+ // stale flag would then make `el.validity.valid` return false on the next
139
+ // submit, silently routing the form to `submit_invalid` and never calling
140
+ // `onSubmit`. Resetting here gives every retry a clean slate.
141
+ if (el) {
142
+ for (const node of Array.from(el.elements) as HTMLInputElement[]) {
143
+ if (typeof node.setCustomValidity === "function") node.setCustomValidity("");
144
+ }
145
+ }
146
+
133
147
  const validationErrors = validateLoginForm(formData, t);
134
148
  internalErrors = validationErrors;
135
149
 
@@ -138,6 +152,20 @@
138
152
  }
139
153
  }
140
154
 
155
+ // Clear internal field errors as soon as the user edits the form, so a previous
156
+ // failed-submit's errors don't linger after the user has fixed them. Re-validation
157
+ // on the next submit will repopulate `internalErrors` if anything is still wrong.
158
+ // `untrack` for the read+write so this effect only re-runs on formData changes —
159
+ // otherwise `handleSubmitValid` setting `internalErrors` would immediately re-fire
160
+ // this effect and wipe the errors back out.
161
+ $effect(() => {
162
+ void formData.email;
163
+ void formData.password;
164
+ untrack(() => {
165
+ if (internalErrors.length) internalErrors = [];
166
+ });
167
+ });
168
+
141
169
  $effect(() => {
142
170
  if (error && notifications) notifications.error(error);
143
171
  });
@@ -88,6 +88,13 @@
88
88
 
89
89
  noXClose?: boolean;
90
90
  onClose?: () => false | void;
91
+
92
+ /**
93
+ * Disable close on backdrop / outside click. Defaults to `true` because
94
+ * accidentally losing typed credentials due to a stray backdrop click is a
95
+ * worse UX than requiring an explicit close. Set to `false` to opt back in.
96
+ */
97
+ noClickOutsideClose?: boolean;
91
98
  }
92
99
  </script>
93
100
 
@@ -126,6 +133,7 @@
126
133
  unstyled = false,
127
134
  noXClose = false,
128
135
  onClose,
136
+ noClickOutsideClose = true,
129
137
  }: Props = $props();
130
138
 
131
139
  let t = $derived(tProp ?? t_default);
@@ -152,6 +160,7 @@
152
160
  class={classModal}
153
161
  classInner={twMerge("max-w-sm md:max-w-sm", "h-auto md:h-auto m-auto", classInner)}
154
162
  classDialog="flex items-center justify-center"
163
+ {noClickOutsideClose}
155
164
  >
156
165
  {#snippet header()}
157
166
  <div class="flex items-center justify-between p-4">
@@ -67,6 +67,12 @@ export interface Props {
67
67
  unstyled?: boolean;
68
68
  noXClose?: boolean;
69
69
  onClose?: () => false | void;
70
+ /**
71
+ * Disable close on backdrop / outside click. Defaults to `true` because
72
+ * accidentally losing typed credentials due to a stray backdrop click is a
73
+ * worse UX than requiring an explicit close. Set to `false` to opt back in.
74
+ */
75
+ noClickOutsideClose?: boolean;
70
76
  }
71
77
  declare const LoginFormModal: import("svelte").Component<Props, {
72
78
  open: (openerOrEvent?: null | HTMLElement | MouseEvent) => void;
@@ -117,6 +117,15 @@
117
117
  >;
118
118
 
119
119
  notifications?: NotificationsStack;
120
+
121
+ /**
122
+ * Called when the active mode changes (login/register/verify). Receives
123
+ * `(next, prev)`. Use this to clear parent-owned, mode-specific state — e.g.,
124
+ * a general `error` string that shouldn't survive a transition between Login
125
+ * and Sign up.
126
+ */
127
+ onModeChange?: (next: LoginOrRegisterFormMode, prev: LoginOrRegisterFormMode) => void;
128
+
120
129
  t?: TranslateFn;
121
130
  unstyled?: boolean;
122
131
  class?: string;
@@ -155,6 +164,7 @@
155
164
  socialDividerLabel,
156
165
  footer,
157
166
  notifications,
167
+ onModeChange,
158
168
  t: tProp,
159
169
  unstyled = false,
160
170
  class: classProp,
@@ -173,6 +183,7 @@
173
183
  // effect (which would be prone to loops).
174
184
  function setMode(next: LoginOrRegisterFormMode) {
175
185
  if (next === mode) return;
186
+ const prev = mode;
176
187
  const sourceEmail =
177
188
  mode === "verify"
178
189
  ? verifyEmail
@@ -187,6 +198,9 @@
187
198
  // next === "verify"
188
199
  verifyEmail = sourceEmail;
189
200
  }
201
+ // Notify before mutating so consumers can clear parent-owned, mode-specific
202
+ // state (e.g., a stale `error`) before the new view renders with it.
203
+ onModeChange?.(next, prev);
190
204
  mode = next;
191
205
  }
192
206
 
@@ -78,6 +78,13 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children">
78
78
  }
79
79
  ]>;
80
80
  notifications?: NotificationsStack;
81
+ /**
82
+ * Called when the active mode changes (login/register/verify). Receives
83
+ * `(next, prev)`. Use this to clear parent-owned, mode-specific state — e.g.,
84
+ * a general `error` string that shouldn't survive a transition between Login
85
+ * and Sign up.
86
+ */
87
+ onModeChange?: (next: LoginOrRegisterFormMode, prev: LoginOrRegisterFormMode) => void;
81
88
  t?: TranslateFn;
82
89
  unstyled?: boolean;
83
90
  class?: string;
@@ -84,6 +84,21 @@
84
84
 
85
85
  noXClose?: boolean;
86
86
  onClose?: () => false | void;
87
+
88
+ /**
89
+ * Disable close on backdrop / outside click. Defaults to `true` because
90
+ * accidentally losing typed credentials due to a stray backdrop click is a
91
+ * worse UX than requiring an explicit close. Set to `false` to opt back in.
92
+ */
93
+ noClickOutsideClose?: boolean;
94
+
95
+ /**
96
+ * Called when the active form mode changes (login/register/verify). Receives
97
+ * `(next, prev)`. Use this to clear parent-owned, mode-specific state — e.g.,
98
+ * a general `error` string that shouldn't survive a transition between Login
99
+ * and Sign up.
100
+ */
101
+ onModeChange?: (next: LoginOrRegisterFormMode, prev: LoginOrRegisterFormMode) => void;
87
102
  }
88
103
  </script>
89
104
 
@@ -129,6 +144,8 @@
129
144
  unstyled = false,
130
145
  noXClose = false,
131
146
  onClose,
147
+ noClickOutsideClose = true,
148
+ onModeChange,
132
149
  }: Props = $props();
133
150
 
134
151
  let t = $derived(tProp ?? t_default);
@@ -163,6 +180,7 @@
163
180
  class={classModal}
164
181
  classInner={twMerge("max-w-sm md:max-w-sm", "h-auto md:h-auto m-auto", classInner)}
165
182
  classDialog="flex items-center justify-center"
183
+ {noClickOutsideClose}
166
184
  >
167
185
  {#snippet header()}
168
186
  <div class="flex items-center justify-between p-4">
@@ -207,6 +225,7 @@
207
225
  {socialDividerLabel}
208
226
  {footer}
209
227
  {notifications}
228
+ {onModeChange}
210
229
  t={tProp}
211
230
  {unstyled}
212
231
  class={classForm}
@@ -59,6 +59,19 @@ export interface Props {
59
59
  unstyled?: boolean;
60
60
  noXClose?: boolean;
61
61
  onClose?: () => false | void;
62
+ /**
63
+ * Disable close on backdrop / outside click. Defaults to `true` because
64
+ * accidentally losing typed credentials due to a stray backdrop click is a
65
+ * worse UX than requiring an explicit close. Set to `false` to opt back in.
66
+ */
67
+ noClickOutsideClose?: boolean;
68
+ /**
69
+ * Called when the active form mode changes (login/register/verify). Receives
70
+ * `(next, prev)`. Use this to clear parent-owned, mode-specific state — e.g.,
71
+ * a general `error` string that shouldn't survive a transition between Login
72
+ * and Sign up.
73
+ */
74
+ onModeChange?: (next: LoginOrRegisterFormMode, prev: LoginOrRegisterFormMode) => void;
62
75
  }
63
76
  declare const LoginOrRegisterFormModal: import("svelte").Component<Props, {
64
77
  open: (openerOrEvent?: null | HTMLElement | MouseEvent) => void;
@@ -22,6 +22,8 @@
22
22
  onEscape?: () => void;
23
23
  /** Disable body scroll lock when modal is open */
24
24
  noScrollLock?: boolean;
25
+ /** Disable close on backdrop / outside click */
26
+ noClickOutsideClose?: boolean;
25
27
  }
26
28
  </script>
27
29
 
@@ -45,6 +47,7 @@
45
47
  el = $bindable(),
46
48
  onEscape,
47
49
  noScrollLock = false,
50
+ noClickOutsideClose = false,
48
51
  }: Props = $props();
49
52
 
50
53
  let modalDialog: ModalDialog = $state()!;
@@ -89,6 +92,7 @@
89
92
  ariaLabelledby={labelledby}
90
93
  ariaDescribedby={describedby}
91
94
  {noScrollLock}
95
+ {noClickOutsideClose}
92
96
  preEscapeClose={handlePreEscapeClose}
93
97
  preClose={handlePreClose}
94
98
  class={twMerge(
@@ -20,6 +20,8 @@ export interface Props {
20
20
  onEscape?: () => void;
21
21
  /** Disable body scroll lock when modal is open */
22
22
  noScrollLock?: boolean;
23
+ /** Disable close on backdrop / outside click */
24
+ noClickOutsideClose?: boolean;
23
25
  }
24
26
  declare const Modal: import("svelte").Component<Props, {
25
27
  close: () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.75.0",
3
+ "version": "3.76.1",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",