react-snackbar-glass 1.1.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2026-10-28
9
+
10
+ ### Added
11
+
12
+ - `layout` option: `'card' | 'compact' | 'inline'`, available per snackbar,
13
+ on `<SnackbarProvider layout="â€Ķ">` and in the store config. `compact: true`
14
+ keeps working as sugar for `layout: 'compact'`.
15
+ - `toast.single(message, options)` / `useSnackbar().single()`: message-only
16
+ status line with a hard one-line contract (ellipsis instead of wrapping),
17
+ no title, no hairline, and no close button while timed.
18
+ - Animation presets: `animation`, `enterAnimation` and `exitAnimation`
19
+ (`'slide' | 'fade' | 'scale' | 'pop' | 'flip' | 'none'`).
20
+ - `--prs-enter-duration` / `--prs-exit-duration` and `--prs-radius-inline`
21
+ CSS variables for pacing and shaping the new modes.
22
+ - Demo: layout and motion selectors in the Studio panel, `toast.single()`
23
+ example, and position buttons now confirm with a single-line toast.
24
+
25
+ ## [1.2.0] - 2026-10-28
26
+
27
+ ### Added
28
+
29
+ - `compact` snackbars: single-line pill, no title or countdown hairline.
30
+ Available per toast (`compact: true`), on the provider
31
+ (`<SnackbarProvider compact />`) and in the store config.
32
+ - Minimize API: `toast.minimize()`, `toast.expand()`, `toast.isMinimized()`
33
+ (also on `useSnackbar()`), collapsing the whole stack into one summary pill
34
+ that expands on click.
35
+ - `minimizedLabel` host prop to customise the collapsed pill text.
36
+
37
+ ### Changed
38
+
39
+ - Demo homepage now defaults to the light theme.
40
+
8
41
  ## [1.1.0] - 2026-10-28
9
42
 
10
43
  ### Added
package/README.md CHANGED
@@ -8,6 +8,9 @@ dependencies**.
8
8
  - 🌍 **Global `toast`** — call it from anywhere, no hook required
9
9
  - âģ **Promise aware** — one call moves a snackbar through loading → success → error
10
10
  - 👆 **Swipe to dismiss**, hover/focus/document-hidden pause, countdown hairline
11
+ - ðŸ“Ķ **Compact + minimize** — one-line pills, or collapse the whole stack
12
+ - ✏ïļ **Single-line status** — `toast.single()` for instant, title-less feedback
13
+ - 🎞ïļ **Pick your motion** — slide, fade, scale, pop, flip or none
11
14
  - â™ŋ **Accessible** — polite/assertive live regions, keyboard reachable actions, `prefers-reduced-motion`
12
15
  - ðŸ§Đ **Typed** — written in TypeScript, ships ESM + CJS + `.d.ts`
13
16
  - ðŸŠķ **Tiny** — React is the only peer dependency
@@ -160,6 +163,11 @@ the snackbar only closes after the handler settles (`closeOnClick`, default
160
163
  | `maxVisible` | `number` | `3` | Simultaneously visible cards per corner. |
161
164
  | `dismissible` | `boolean` | `true` | Show the close button. |
162
165
  | `progress` | `boolean` | `true` | Show the countdown hairline. |
166
+ | `compact` | `boolean` | `false` | Single-line pill for every snackbar. |
167
+ | `layout` | `'card' \| 'compact' \| 'inline'` | `'card'` | Default layout; `compact` is sugar for `'compact'`. |
168
+ | `animation` | `'slide' \| 'fade' \| 'scale' \| 'pop' \| 'flip' \| 'none'` | `'slide'` | Enter + exit animation preset. |
169
+ | `enterAnimation` / `exitAnimation` | same as `animation` | — | Override a single phase. |
170
+ | `minimizedLabel` | `(count: number) => ReactNode` | `"3 notifications"` | Text on the collapsed pill. |
163
171
  | `pauseOnHover` | `boolean` | `true` | Freeze the timer while hovered. |
164
172
  | `pauseOnFocus` | `boolean` | `true` | Freeze the timer while focus is inside. |
165
173
  | `pauseOnDocumentHidden` | `boolean` | `true` | Pause when the tab is hidden. |
@@ -175,12 +183,82 @@ the snackbar only closes after the handler settles (`closeOnClick`, default
175
183
  ## `SnackbarOptions`
176
184
 
177
185
  `id`, `title`, `message`, `variant`, `duration`, `position`, `icon`,
178
- `action`, `dismissible`, `progress`, `tone`, `size`, `className`, `style`,
186
+ `action`, `dismissible`, `progress`, `compact`, `layout`, `animation`,
187
+ `enterAnimation`, `exitAnimation`, `tone`, `size`, `className`, `style`,
179
188
  `onOpen`, `onClose`.
180
189
 
181
190
  Reusing an `id` updates (or revives) the existing snackbar instead of stacking
182
191
  a duplicate — ideal for progress-style flows.
183
192
 
193
+ ## Single-line status notices
194
+
195
+ `toast.single()` is the "one line, no ceremony" preset: content width, a hard
196
+ one-line contract (long text truncates with an ellipsis instead of wrapping),
197
+ no title, no countdown hairline and no close button while it is timed.
198
+
199
+ ```ts
200
+ toast.single('Position set to top-center', { variant: 'success' });
201
+ toast.single('Could not reach the server', { variant: 'error', duration: 8000 });
202
+ ```
203
+
204
+ It is `layout: 'inline'` under the hood, so every entry point can do the same:
205
+
206
+ ```ts
207
+ snackbar.show({ message: 'Synced', layout: 'inline' });
208
+ useSnackbar().single('Copied to clipboard');
209
+ <SnackbarProvider layout="inline" />;
210
+ ```
211
+
212
+ ## Animations
213
+
214
+ One preset drives both phases; `enterAnimation` / `exitAnimation` override a
215
+ single phase:
216
+
217
+ ```ts
218
+ toast.success('Saved', { animation: 'fade' });
219
+ toast.error('Failed', { animation: 'scale', exitAnimation: 'none' });
220
+ <SnackbarProvider animation="pop" />;
221
+ ```
222
+
223
+ | Preset | Feel |
224
+ | --- | --- |
225
+ | `slide` | Spring slide from the nearest edge with a soft blur (default). |
226
+ | `fade` | Pure opacity cross-fade. |
227
+ | `scale` | Zoom from the center. |
228
+ | `pop` | Bouncy overshoot. |
229
+ | `flip` | 3D flip on the X axis. |
230
+ | `none` | Instant, no motion. |
231
+
232
+ Pace them with `--prs-enter-duration` / `--prs-exit-duration` (defaults
233
+ `440ms` / `190ms`); `prefers-reduced-motion` still disables everything.
234
+
235
+ ## Compact and minimized stacks
236
+
237
+ Two ways to reclaim screen space when snackbars would otherwise pile up.
238
+
239
+ **Compact** — a single-line pill per snackbar (no title, no hairline):
240
+
241
+ ```ts
242
+ toast.success('Draft saved', { compact: true });
243
+ snackbar.show({ message: 'Synced', compact: true });
244
+ ```
245
+
246
+ Set it once for every snackbar with `<SnackbarProvider compact />` or
247
+ `createSnackbarStore({ compact: true })`.
248
+
249
+ **Minimized** — collapse the whole stack into one summary pill
250
+ (`"3 notifications"`) that expands on click:
251
+
252
+ ```ts
253
+ toast.minimize(); // stack → one pill
254
+ toast.expand(); // back to full cards
255
+ toast.isMinimized(); // boolean
256
+ ```
257
+
258
+ `useSnackbar()` exposes the same three methods, and the store has
259
+ `setMinimized(true | false)` + `getMinimized()`. Customize the pill text with
260
+ `minimizedLabel={(count) => `${count} neue Meldungen`}`.
261
+
184
262
  ## Theming
185
263
 
186
264
  Colors, radii, blur and motion are CSS custom properties on `.prs-root`
package/dist/index.cjs CHANGED
@@ -11,6 +11,10 @@ var DEFAULT_CONFIG = {
11
11
  duration: 5e3,
12
12
  dismissible: true,
13
13
  progress: true,
14
+ layout: "card",
15
+ animation: "slide",
16
+ enterAnimation: "slide",
17
+ exitAnimation: "slide",
14
18
  tone: "glass",
15
19
  size: "md",
16
20
  maxVisible: 3
@@ -38,28 +42,45 @@ function resolveContent(content, value) {
38
42
  const resolved = typeof content === "function" ? content(value) : content;
39
43
  return contentToOptions(resolved);
40
44
  }
41
- function createSnackbarStore(initialConfig = {}) {
42
- let config = {
43
- ...DEFAULT_CONFIG,
44
- ...initialConfig.position !== void 0 ? { position: initialConfig.position } : null,
45
- ...initialConfig.duration !== void 0 ? {
46
- duration: normalizeDuration(
47
- initialConfig.duration,
48
- DEFAULT_CONFIG.duration
49
- )
50
- } : null,
51
- ...initialConfig.dismissible !== void 0 ? { dismissible: initialConfig.dismissible } : null,
52
- ...initialConfig.progress !== void 0 ? { progress: initialConfig.progress } : null,
53
- ...initialConfig.tone !== void 0 ? { tone: initialConfig.tone } : null,
54
- ...initialConfig.size !== void 0 ? { size: initialConfig.size } : null,
55
- ...initialConfig.maxVisible !== void 0 ? { maxVisible: Math.max(1, Math.floor(initialConfig.maxVisible)) } : null
45
+ function resolveLayout(layout, compact, fallback) {
46
+ if (layout !== void 0) return layout;
47
+ if (compact !== void 0) return compact ? "compact" : "card";
48
+ return fallback;
49
+ }
50
+ function resolveLayoutPatch(record, options) {
51
+ if (options.layout === void 0 && options.compact === void 0) return {};
52
+ const layout = resolveLayout(options.layout, options.compact, record.layout);
53
+ return { layout, compact: layout === "compact" };
54
+ }
55
+ function mergeConfig(previous, patch) {
56
+ const layout = resolveLayout(patch.layout, patch.compact, previous.layout);
57
+ return {
58
+ position: patch.position ?? previous.position,
59
+ duration: patch.duration !== void 0 ? normalizeDuration(patch.duration, previous.duration) : previous.duration,
60
+ dismissible: patch.dismissible ?? previous.dismissible,
61
+ progress: patch.progress ?? previous.progress,
62
+ compact: layout === "compact",
63
+ layout,
64
+ animation: patch.animation ?? previous.animation,
65
+ enterAnimation: patch.enterAnimation ?? patch.animation ?? previous.enterAnimation,
66
+ exitAnimation: patch.exitAnimation ?? patch.animation ?? previous.exitAnimation,
67
+ tone: patch.tone ?? previous.tone,
68
+ size: patch.size ?? previous.size,
69
+ maxVisible: patch.maxVisible !== void 0 ? Math.max(1, Math.floor(patch.maxVisible)) : previous.maxVisible
56
70
  };
71
+ }
72
+ function createSnackbarStore(initialConfig = {}) {
73
+ let config = mergeConfig(
74
+ DEFAULT_CONFIG,
75
+ initialConfig
76
+ );
57
77
  let records = EMPTY;
58
78
  const listeners = /* @__PURE__ */ new Set();
59
79
  const timers = /* @__PURE__ */ new Map();
60
80
  const shown = /* @__PURE__ */ new Set();
61
81
  const pauseReasons = /* @__PURE__ */ new Map();
62
82
  let pageHidden = false;
83
+ let minimized = false;
63
84
  const emit = () => {
64
85
  for (const listener of listeners) listener();
65
86
  };
@@ -220,6 +241,7 @@ function createSnackbarStore(initialConfig = {}) {
220
241
  }
221
242
  return id;
222
243
  }
244
+ const layout = resolveLayout(options.layout, options.compact, config.layout);
223
245
  const record = {
224
246
  id,
225
247
  title: options.title,
@@ -233,6 +255,11 @@ function createSnackbarStore(initialConfig = {}) {
233
255
  action: options.action ?? null,
234
256
  dismissible: options.dismissible ?? config.dismissible,
235
257
  progress: options.progress ?? config.progress,
258
+ compact: layout === "compact",
259
+ layout,
260
+ animation: options.animation ?? config.animation,
261
+ enterAnimation: options.enterAnimation ?? options.animation ?? config.enterAnimation,
262
+ exitAnimation: options.exitAnimation ?? options.animation ?? config.exitAnimation,
236
263
  className: options.className,
237
264
  style: options.style,
238
265
  createdAt: Date.now(),
@@ -261,6 +288,14 @@ function createSnackbarStore(initialConfig = {}) {
261
288
  ...options.action !== void 0 ? { action: options.action } : null,
262
289
  ...options.dismissible !== void 0 ? { dismissible: options.dismissible } : null,
263
290
  ...options.progress !== void 0 ? { progress: options.progress } : null,
291
+ ...resolveLayoutPatch(record, options),
292
+ ...options.animation !== void 0 ? {
293
+ animation: options.animation,
294
+ enterAnimation: options.animation,
295
+ exitAnimation: options.animation
296
+ } : null,
297
+ ...options.enterAnimation !== void 0 ? { enterAnimation: options.enterAnimation } : null,
298
+ ...options.exitAnimation !== void 0 ? { exitAnimation: options.exitAnimation } : null,
264
299
  ...options.className !== void 0 ? { className: options.className } : null,
265
300
  ...options.style !== void 0 ? { style: options.style } : null,
266
301
  ...options.onOpen !== void 0 ? { onOpen: options.onOpen } : null,
@@ -310,18 +345,7 @@ function createSnackbarStore(initialConfig = {}) {
310
345
  }
311
346
  };
312
347
  const setConfig = (patch) => {
313
- config = {
314
- ...config,
315
- ...patch.position !== void 0 ? { position: patch.position } : null,
316
- ...patch.duration !== void 0 ? {
317
- duration: normalizeDuration(patch.duration, config.duration)
318
- } : null,
319
- ...patch.dismissible !== void 0 ? { dismissible: patch.dismissible } : null,
320
- ...patch.progress !== void 0 ? { progress: patch.progress } : null,
321
- ...patch.tone !== void 0 ? { tone: patch.tone } : null,
322
- ...patch.size !== void 0 ? { size: patch.size } : null,
323
- ...patch.maxVisible !== void 0 ? { maxVisible: Math.max(1, Math.floor(patch.maxVisible)) } : null
324
- };
348
+ config = mergeConfig(config, patch);
325
349
  };
326
350
  const promise = (input, messages, options = {}) => {
327
351
  const loadingOptions = contentToOptions(messages.loading ?? "Loading\u2026");
@@ -372,7 +396,14 @@ function createSnackbarStore(initialConfig = {}) {
372
396
  resume,
373
397
  notifyShown,
374
398
  notifyExited,
375
- setPageHidden
399
+ setPageHidden,
400
+ getMinimized: () => minimized,
401
+ getServerMinimized: () => false,
402
+ setMinimized(next) {
403
+ if (minimized === next) return;
404
+ minimized = next;
405
+ emit();
406
+ }
376
407
  };
377
408
  }
378
409
 
@@ -617,7 +648,10 @@ function SnackbarItem({
617
648
  };
618
649
  const resolvedIcon = record.icon === void 0 ? icons?.[variant] ?? defaultIcons[variant] : record.icon;
619
650
  const showIcon = record.icon !== false;
620
- const showProgress = record.progress && Number.isFinite(duration) && duration > 0;
651
+ const isInline = record.layout === "inline";
652
+ const isTimed = Number.isFinite(duration) && duration > 0;
653
+ const showProgress = record.layout === "card" && record.progress && isTimed;
654
+ const showClose = record.dismissible && (!isInline || !isTimed);
621
655
  const style = {
622
656
  ...record.style,
623
657
  "--prs-drag-x": `${dragX}px`,
@@ -637,6 +671,10 @@ function SnackbarItem({
637
671
  "data-dragging": dragging ? "true" : "false",
638
672
  "data-has-icon": showIcon ? "true" : "false",
639
673
  "data-dismissible": record.dismissible ? "true" : "false",
674
+ "data-compact": record.compact ? "true" : "false",
675
+ "data-layout": record.layout,
676
+ "data-enter": record.enterAnimation,
677
+ "data-exit": record.exitAnimation,
640
678
  "data-prs-id": record.id,
641
679
  "data-position": record.position,
642
680
  style,
@@ -685,7 +723,7 @@ function SnackbarItem({
685
723
  className: "prs-item__content",
686
724
  "data-has-title": title !== void 0 && title !== null ? "true" : "false",
687
725
  children: [
688
- title !== void 0 && title !== null ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "prs-item__title", children: title }) : null,
726
+ title !== void 0 && title !== null && !isInline ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "prs-item__title", children: title }) : null,
689
727
  message !== void 0 && message !== null ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "prs-item__message", children: message }) : null,
690
728
  action ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "prs-item__actions", children: /* @__PURE__ */ jsxRuntime.jsxs(
691
729
  "button",
@@ -704,7 +742,7 @@ function SnackbarItem({
704
742
  ]
705
743
  }
706
744
  ),
707
- record.dismissible ? /* @__PURE__ */ jsxRuntime.jsx(
745
+ showClose ? /* @__PURE__ */ jsxRuntime.jsx(
708
746
  "button",
709
747
  {
710
748
  type: "button",
@@ -736,6 +774,9 @@ var POSITIONS = [
736
774
  "bottom-center",
737
775
  "bottom-right"
738
776
  ];
777
+ function defaultMinimizedLabel(count) {
778
+ return `${count} notification${count === 1 ? "" : "s"}`;
779
+ }
739
780
  function SnackbarHost({
740
781
  store,
741
782
  maxVisible = 3,
@@ -753,6 +794,7 @@ function SnackbarHost({
753
794
  closeIcon,
754
795
  closeLabel = "Dismiss notification",
755
796
  regionLabel = "Notifications",
797
+ minimizedLabel = defaultMinimizedLabel,
756
798
  className,
757
799
  style,
758
800
  zIndex = 2147483e3,
@@ -763,6 +805,11 @@ function SnackbarHost({
763
805
  store.getSnapshot,
764
806
  store.getServerSnapshot
765
807
  );
808
+ const minimized = react.useSyncExternalStore(
809
+ store.subscribe,
810
+ store.getMinimized,
811
+ store.getServerMinimized
812
+ );
766
813
  const [portalNode, setPortalNode] = react.useState(null);
767
814
  const regionRefs = react.useRef(/* @__PURE__ */ new Map());
768
815
  const topsRef = react.useRef(/* @__PURE__ */ new Map());
@@ -826,6 +873,7 @@ function SnackbarHost({
826
873
  else byPosition.set(record.position, [record]);
827
874
  }
828
875
  const limit = Math.max(1, Math.floor(maxVisible));
876
+ const minimizedPosition = records[0]?.position ?? null;
829
877
  const rootStyle = {
830
878
  "--prs-width": `${width}px`,
831
879
  "--prs-offset": `${offset}px`,
@@ -844,6 +892,32 @@ function SnackbarHost({
844
892
  children: POSITIONS.map((position) => {
845
893
  const items = byPosition.get(position);
846
894
  if (!items || items.length === 0) return null;
895
+ if (minimized) {
896
+ if (position !== minimizedPosition) return null;
897
+ return /* @__PURE__ */ jsxRuntime.jsx(
898
+ "section",
899
+ {
900
+ className: "prs-region prs-region--minimized",
901
+ "data-position": position,
902
+ "aria-label": regionLabel,
903
+ children: /* @__PURE__ */ jsxRuntime.jsxs(
904
+ "button",
905
+ {
906
+ type: "button",
907
+ className: "prs-minimized",
908
+ "data-count": items.length,
909
+ onClick: () => store.setMinimized(false),
910
+ title: "Expand notifications",
911
+ children: [
912
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "prs-minimized__dot", "aria-hidden": "true" }),
913
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "prs-minimized__count", children: minimizedLabel(items.length) })
914
+ ]
915
+ }
916
+ )
917
+ },
918
+ position
919
+ );
920
+ }
847
921
  const visible = items.slice(0, limit);
848
922
  return /* @__PURE__ */ jsxRuntime.jsx(
849
923
  "section",
@@ -887,6 +961,11 @@ function SnackbarProvider({
887
961
  duration,
888
962
  dismissible,
889
963
  progress,
964
+ compact,
965
+ layout,
966
+ animation,
967
+ enterAnimation,
968
+ exitAnimation,
890
969
  tone,
891
970
  size,
892
971
  maxVisible,
@@ -908,11 +987,30 @@ function SnackbarProvider({
908
987
  duration,
909
988
  dismissible,
910
989
  progress,
990
+ compact,
991
+ layout,
992
+ animation,
993
+ enterAnimation,
994
+ exitAnimation,
911
995
  tone,
912
996
  size,
913
997
  maxVisible
914
998
  });
915
- }, [store, position, duration, dismissible, progress, tone, size, maxVisible]);
999
+ }, [
1000
+ store,
1001
+ position,
1002
+ duration,
1003
+ dismissible,
1004
+ progress,
1005
+ compact,
1006
+ layout,
1007
+ animation,
1008
+ enterAnimation,
1009
+ exitAnimation,
1010
+ tone,
1011
+ size,
1012
+ maxVisible
1013
+ ]);
916
1014
  const api = react.useMemo(() => {
917
1015
  const preset2 = (variant) => (message, options = {}) => store.show({ ...options, message, variant });
918
1016
  return {
@@ -927,12 +1025,16 @@ function SnackbarProvider({
927
1025
  info: preset2("info"),
928
1026
  neutral: preset2("neutral"),
929
1027
  custom: preset2("custom"),
1028
+ single: (message, options = {}) => store.show({ ...options, message, layout: "inline" }),
930
1029
  loading: (message, options = {}) => store.show({
931
1030
  ...options,
932
1031
  message,
933
1032
  variant: "loading",
934
1033
  duration: options.duration !== void 0 ? options.duration : null
935
1034
  }),
1035
+ minimize: () => store.setMinimized(true),
1036
+ expand: () => store.setMinimized(false),
1037
+ isMinimized: () => store.getMinimized(),
936
1038
  store
937
1039
  };
938
1040
  }, [store]);
@@ -990,6 +1092,7 @@ function createToast() {
990
1092
  toast2.info = preset("info");
991
1093
  toast2.neutral = preset("neutral");
992
1094
  toast2.custom = preset("custom");
1095
+ toast2.single = (message, options = {}) => getDefaultStore().show({ ...options, message, layout: "inline" });
993
1096
  toast2.loading = (message, options = {}) => getDefaultStore().show({
994
1097
  ...options,
995
1098
  message,
@@ -997,6 +1100,9 @@ function createToast() {
997
1100
  duration: options.duration !== void 0 ? options.duration : null
998
1101
  });
999
1102
  toast2.promise = (input, messages, options) => getDefaultStore().promise(input, messages, options);
1103
+ toast2.minimize = () => getDefaultStore().setMinimized(true);
1104
+ toast2.expand = () => getDefaultStore().setMinimized(false);
1105
+ toast2.isMinimized = () => getDefaultStore().getMinimized();
1000
1106
  Object.defineProperty(toast2, "store", {
1001
1107
  get: getDefaultStore,
1002
1108
  enumerable: true