@sovereignfs/ui 0.21.1 → 0.21.3

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.
@@ -48,7 +48,15 @@
48
48
  .content {
49
49
  flex: 1 1 auto;
50
50
  overflow-y: auto;
51
+ overflow-x: hidden;
51
52
  padding: var(--sv-space-6);
53
+ /* Disable elastic overscroll within the dialog: `none` stops both scroll-
54
+ chaining to the parent and the iOS rubber-band bounce inside the panel.
55
+ `contain` only prevents chaining — the panel itself still bounces, which
56
+ reveals white panel-background between the mobileBar and the sticky tab
57
+ strip on iOS PWA. A dialog is a bounded surface where bounce is a visual
58
+ bug, not a feature. */
59
+ overscroll-behavior: none;
52
60
  }
53
61
 
54
62
  /* Fixed sizes. `max-*: 100%` caps to the scrim's content box (viewport minus
@@ -20,6 +20,7 @@
20
20
  width: 100%;
21
21
  max-height: 80dvh;
22
22
  overflow-y: auto;
23
+ overscroll-behavior: contain;
23
24
  background: var(--sv-color-surface-raised);
24
25
  border-top: 1px solid var(--sv-color-border);
25
26
  border-radius: var(--sv-radius-3xl) var(--sv-radius-3xl) 0 0;
package/dist/index.js CHANGED
@@ -905,6 +905,20 @@ function Input({ type = "text", className, ...rest }) {
905
905
 
906
906
  // src/components/Dialog/Dialog.tsx
907
907
  import { useEffect as useEffect2, useRef as useRef2 } from "react";
908
+
909
+ // src/scroll-lock.ts
910
+ function lockBodyScroll() {
911
+ const count = parseInt(document.body.dataset.scrollLocks ?? "0", 10);
912
+ document.body.dataset.scrollLocks = String(count + 1);
913
+ if (count === 0) document.body.style.overflowY = "hidden";
914
+ }
915
+ function unlockBodyScroll() {
916
+ const next = Math.max(0, parseInt(document.body.dataset.scrollLocks ?? "0", 10) - 1);
917
+ document.body.dataset.scrollLocks = String(next);
918
+ if (next === 0) document.body.style.overflowY = "";
919
+ }
920
+
921
+ // src/components/Dialog/Dialog.tsx
908
922
  import styles11 from "./Dialog.module.css";
909
923
  import { jsx as jsx39, jsxs as jsxs26 } from "react/jsx-runtime";
910
924
  var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
@@ -918,6 +932,11 @@ function Dialog({
918
932
  }) {
919
933
  const panelRef = useRef2(null);
920
934
  const previouslyFocused = useRef2(null);
935
+ useEffect2(() => {
936
+ if (!open) return;
937
+ lockBodyScroll();
938
+ return unlockBodyScroll;
939
+ }, [open]);
921
940
  useEffect2(() => {
922
941
  if (!open) return;
923
942
  previouslyFocused.current = document.activeElement;
@@ -1012,6 +1031,11 @@ var FOCUSABLE2 = 'a[href], button:not([disabled]), textarea:not([disabled]), inp
1012
1031
  function Drawer({ open, onClose, "aria-label": ariaLabel, children }) {
1013
1032
  const panelRef = useRef3(null);
1014
1033
  const previouslyFocused = useRef3(null);
1034
+ useEffect3(() => {
1035
+ if (!open) return;
1036
+ lockBodyScroll();
1037
+ return unlockBodyScroll;
1038
+ }, [open]);
1015
1039
  useEffect3(() => {
1016
1040
  if (!open) return;
1017
1041
  previouslyFocused.current = document.activeElement;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovereignfs/ui",
3
- "version": "0.21.1",
3
+ "version": "0.21.3",
4
4
  "type": "module",
5
5
  "description": "Sovereign Design System — design tokens and React components.",
6
6
  "keywords": [
@@ -48,7 +48,15 @@
48
48
  .content {
49
49
  flex: 1 1 auto;
50
50
  overflow-y: auto;
51
+ overflow-x: hidden;
51
52
  padding: var(--sv-space-6);
53
+ /* Disable elastic overscroll within the dialog: `none` stops both scroll-
54
+ chaining to the parent and the iOS rubber-band bounce inside the panel.
55
+ `contain` only prevents chaining — the panel itself still bounces, which
56
+ reveals white panel-background between the mobileBar and the sticky tab
57
+ strip on iOS PWA. A dialog is a bounded surface where bounce is a visual
58
+ bug, not a feature. */
59
+ overscroll-behavior: none;
52
60
  }
53
61
 
54
62
  /* Fixed sizes. `max-*: 100%` caps to the scrim's content box (viewport minus
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { type ReactNode, useEffect, useRef } from 'react';
4
+ import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
4
5
  import styles from './Dialog.module.css';
5
6
 
6
7
  export type DialogSize = 'sm' | 'md' | 'lg' | 'full';
@@ -44,6 +45,15 @@ export function Dialog({
44
45
  const panelRef = useRef<HTMLDivElement>(null);
45
46
  const previouslyFocused = useRef<HTMLElement | null>(null);
46
47
 
48
+ // Prevent document-level scroll while open. Ref-counted so nested overlays
49
+ // (e.g. a confirmation dialog inside an overlay plugin) don't release the
50
+ // lock while a sibling is still open.
51
+ useEffect(() => {
52
+ if (!open) return;
53
+ lockBodyScroll();
54
+ return unlockBodyScroll;
55
+ }, [open]);
56
+
47
57
  // Capture focus on open; restore it on close/unmount.
48
58
  useEffect(() => {
49
59
  if (!open) return;
@@ -20,6 +20,7 @@
20
20
  width: 100%;
21
21
  max-height: 80dvh;
22
22
  overflow-y: auto;
23
+ overscroll-behavior: contain;
23
24
  background: var(--sv-color-surface-raised);
24
25
  border-top: 1px solid var(--sv-color-border);
25
26
  border-radius: var(--sv-radius-3xl) var(--sv-radius-3xl) 0 0;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { type ReactNode, useEffect, useRef } from 'react';
4
+ import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
4
5
  import styles from './Drawer.module.css';
5
6
 
6
7
  export interface DrawerProps {
@@ -30,6 +31,12 @@ export function Drawer({ open, onClose, 'aria-label': ariaLabel, children }: Dra
30
31
  const panelRef = useRef<HTMLDivElement>(null);
31
32
  const previouslyFocused = useRef<HTMLElement | null>(null);
32
33
 
34
+ useEffect(() => {
35
+ if (!open) return;
36
+ lockBodyScroll();
37
+ return unlockBodyScroll;
38
+ }, [open]);
39
+
33
40
  useEffect(() => {
34
41
  if (!open) return;
35
42
  previouslyFocused.current = document.activeElement as HTMLElement | null;
@@ -0,0 +1,22 @@
1
+ // Ref-counted body scroll lock. Multiple overlays (Dialog, Drawer) can be
2
+ // open simultaneously; the body stays locked until all of them close.
3
+ // Uses data-scroll-locks on <body> as a counter so concurrent callers
4
+ // don't clobber each other's lock state.
5
+ //
6
+ // Only locks overflow-y (not the shorthand overflow) so the stylesheet's
7
+ // overflow-x: hidden on html,body is never overridden. On Android, clearing
8
+ // the overflow shorthand causes a brief frame where overflow-x is also
9
+ // unlocked — content wider than the viewport can paint and the home screen
10
+ // appears to "blow out" after the overlay closes.
11
+
12
+ export function lockBodyScroll(): void {
13
+ const count = parseInt(document.body.dataset.scrollLocks ?? '0', 10);
14
+ document.body.dataset.scrollLocks = String(count + 1);
15
+ if (count === 0) document.body.style.overflowY = 'hidden';
16
+ }
17
+
18
+ export function unlockBodyScroll(): void {
19
+ const next = Math.max(0, parseInt(document.body.dataset.scrollLocks ?? '0', 10) - 1);
20
+ document.body.dataset.scrollLocks = String(next);
21
+ if (next === 0) document.body.style.overflowY = '';
22
+ }