@djangocfg/layouts 2.1.612 → 2.1.615

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.612",
3
+ "version": "2.1.615",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -91,11 +91,11 @@
91
91
  "test": "vitest run"
92
92
  },
93
93
  "peerDependencies": {
94
- "@djangocfg/analytics": "^2.1.612",
95
- "@djangocfg/api": "^2.1.612",
96
- "@djangocfg/devtools": "^2.1.612",
97
- "@djangocfg/i18n": "^2.1.612",
98
- "@djangocfg/ui-core": "^2.1.612",
94
+ "@djangocfg/analytics": "^2.1.615",
95
+ "@djangocfg/api": "^2.1.615",
96
+ "@djangocfg/devtools": "^2.1.615",
97
+ "@djangocfg/i18n": "^2.1.615",
98
+ "@djangocfg/ui-core": "^2.1.615",
99
99
  "@hookform/resolvers": "^5.2.2 || ^7.0.2",
100
100
  "consola": "^3.4.2",
101
101
  "lucide-react": "^1.68.0",
@@ -106,7 +106,7 @@
106
106
  "react": "^19.0.0",
107
107
  "react-dom": "^19.0.0",
108
108
  "react-hook-form": "^7.69.0",
109
- "sonner": "2.0.7",
109
+ "sonner": "^2.0.7",
110
110
  "swr": "^2.3.7",
111
111
  "tailwindcss": "^4.1.18",
112
112
  "tailwindcss-animate": "^1.0.7",
@@ -124,18 +124,19 @@
124
124
  "qrcode.react": "^4.2.0"
125
125
  },
126
126
  "devDependencies": {
127
- "@djangocfg/analytics": "^2.1.612",
128
- "@djangocfg/api": "^2.1.612",
129
- "@djangocfg/devtools": "^2.1.612",
130
- "@djangocfg/i18n": "^2.1.612",
131
- "@djangocfg/typescript-config": "^2.1.612",
132
- "@djangocfg/ui-core": "^2.1.612",
127
+ "@djangocfg/analytics": "^2.1.615",
128
+ "@djangocfg/api": "^2.1.615",
129
+ "@djangocfg/devtools": "^2.1.615",
130
+ "@djangocfg/i18n": "^2.1.615",
131
+ "@djangocfg/typescript-config": "^2.1.615",
132
+ "@djangocfg/ui-core": "^2.1.615",
133
133
  "@storybook/react-vite": "^10.6.5",
134
134
  "@types/node": "^25.9.5",
135
135
  "@types/react": "19.3.0",
136
136
  "@types/react-dom": "19.3.0",
137
137
  "eslint": "^10.14.0",
138
138
  "next-intl": "^4.14.9",
139
+ "sonner": "2.0.8",
139
140
  "storybook": "^10.6.5",
140
141
  "typescript": "^5.9.3",
141
142
  "vitest": "^5.1.0",
@@ -79,8 +79,10 @@
79
79
  height: 1.25rem;
80
80
  }
81
81
 
82
- /* The locale stays at the right edge even when the link is hidden. */
83
- .auth-topbar > :last-child {
82
+ /* The locale stays at the right edge even when the link is hidden. `:not(:first-child)`
83
+ keeps the rule off the link itself: without a locale switcher the link is the
84
+ only child, hence also `:last-child`, and it would be pushed to the right. */
85
+ .auth-topbar > :last-child:not(:first-child) {
84
86
  margin-inline-start: auto;
85
87
  }
86
88
 
@@ -280,7 +280,7 @@ const header: HeaderConfig = {
280
280
  |---|---|
281
281
  | Docked | Width from `--sidebar-width`; drag the right edge, arrow keys, double-click to reset. |
282
282
  | Collapsed (desktop) | Hidden. The top bar (toggle top-left, title, actions, avatar) shows; resting the pointer at the left screen edge (not on the toggle) floats the sidebar over the page. The panel stays while the pointer is on it or a menu opened from it is open; Esc, navigation and moving away close it. Clicking the trigger docks it. |
283
- | Mobile (< md) | Drawer 88vw wide (max 22rem, the dimmed strip taps it closed) and full height (`fixed inset-y-0`: the real visible area in a browser tab and in an iOS standalone PWA): 40px rows (44px hit area), 14px text, 20px icons, 8px container gutter + 10px row padding (icon column and logo at 18px; dividers, highlighted rows and footer share the 8px gutter), header is the brand only (48px under safe-top, no toggle or close button; it closes by backdrop tap, swipe, Escape or navigating). Three zones: pinned header (gets a bottom hairline once the nav scrolls), the nav as the ONLY scroller (`overscroll-contain`), pinned footer with a full-width top border and its own background; the drawer panel itself is `overflow-hidden` (`DrawerContent bare`). Header and footer respect the safe areas on top, bottom and sides (at least 1rem vertically). On a phone (below md) the layout draws a plain top bar: `safe-top + 3rem` tall (`--layout-bar-height`, `0px` from md up and without a sidebar), `bg-background` with a 1px bottom border, holding the sidebar toggle (the same ghost button as in the sidebar header) and `header.title`. It overlays the content column and slides away (`transform`, 200ms) on scroll-down and back on scroll-up or at the top, using the same `useNavbarScroll` policy as `PublicLayout` driven by the content scroller; it stays while the drawer is open, a field has focus, or the page is not scrollable. Content starts below the bar for every page, so pages do nothing; apps may use `--layout-bar-height` to offset their own sticky elements. `PrivateLayout` takes `topBar={{ title, actions }}` (types: `PrivateTopBarConfig`): `title` replaces `header.title` in the bar, `actions` is a ReactNode on the right (vertically centred, never shrinks the toggle; the title takes the rest and truncates). The same bar shows on desktop while the sidebar is collapsed (static, no auto-hide) and carries the account avatar for a signed-in user (`topBar.account={false}` to opt out); on a phone the drawer then has no footer account. Recommended actions are compact ghost icon `Button`s (36px); several are fine, keep them few. The toggle stays left and is not configurable. It is rendered by the phone bar only; desktop shows nothing (the sidebar is the navigation). |
283
+ | Mobile (< md) | Drawer 88vw wide (max 22rem, the dimmed strip taps it closed) and full height (`fixed inset-y-0`: the real visible area in a browser tab and in an iOS standalone PWA): 40px rows (44px hit area), 14px text, 20px icons, 8px container gutter + 10px row padding (icon column and logo at 18px; dividers, highlighted rows and footer share the 8px gutter), header is the brand only (48px under safe-top, no toggle or close button; it closes by backdrop tap, swipe, Escape or navigating). Three zones: pinned header (gets a bottom hairline once the nav scrolls), the nav as the ONLY scroller (`overscroll-contain`), pinned footer with a full-width top border and its own background; the drawer panel itself is `overflow-hidden` (`DrawerContent bare`). Header and footer respect the safe areas on top, bottom and sides (at least 1rem vertically). On a phone (below md) the layout draws a plain top bar: `safe-top + 3rem` tall (`--layout-bar-height`, `0px` from md up and without a sidebar), `bg-background` with a 1px bottom border, holding the sidebar toggle (the same ghost button as in the sidebar header) and `header.title`. It overlays the content column and never hides on scroll. Content starts below the bar for every page, so pages do nothing; apps may use `--layout-bar-height` to offset their own sticky elements. `PrivateLayout` takes `topBar={{ title, actions }}` (types: `PrivateTopBarConfig`): `title` replaces `header.title` in the bar, `actions` is a ReactNode on the right (vertically centred, never shrinks the toggle; the title takes the rest and truncates). The same bar shows on desktop while the sidebar is collapsed (static: it never hides on scroll) and carries the account avatar for a signed-in user (`topBar.account={false}` to opt out); on a phone the drawer then has no footer account. Recommended actions are compact ghost icon `Button`s (36px); several are fine, keep them few. The toggle stays left and is not configurable. It is rendered by the phone bar only; desktop shows nothing (the sidebar is the navigation). |
284
284
 
285
285
  Row metrics (`geometry.ts`): desktop rows 36px, mobile rows 40px (with a 44px hit area from an invisible `::after`), both `text-sm` with 16px (desktop) or 20px (mobile) icons and group labels in `text-xs`. Safe-area insets only exist when the host app sets `viewportFit: "cover"` in its Next.js `viewport` export; without it the offsets fall back to the fixed minimums. For an installed iOS PWA also set `appleWebApp.statusBarStyle` (`black-translucent` draws the page under the status bar and relies on the insets; `default` keeps it below). The drawer height is pinned with `inset-y-0` rather than `100dvh`, which can disagree with the visible area in standalone mode.
286
286
 
@@ -85,13 +85,13 @@ describe('PrivateContent top bar', () => {
85
85
  expect(bar.className).toContain('pt-[var(--safe-top');
86
86
  expect(bar.className).toContain('pl-[calc(var(--safe-left');
87
87
  expect(bar.className).toContain('pr-[calc(var(--safe-right');
88
- // Overlays the column (transform-hidden), so hiding never reflows; the content pads by the bar height.
88
+ // Overlays the column; the content pads by the bar height. It never moves on scroll.
89
89
  expect(bar.className).toMatch(/\babsolute\b/);
90
- expect(bar.className).toContain('transition-transform');
90
+ expect(bar.className).not.toContain('translate');
91
+ expect(bar.hasAttribute('data-hidden')).toBe(false);
91
92
  expect((bar.nextElementSibling as HTMLElement).className).toContain('layout-bar-height');
92
93
  expect(bar.querySelector('[data-sidebar="trigger"]')!.className).not.toMatch(/\b(rounded-full|border|shadow)/);
93
94
  expect(bar.textContent).toBe('Acme');
94
- expect(bar.getAttribute('data-hidden')).toBe('false');
95
95
  // Content is the next sibling: it starts under the bar and carries no trigger offsets.
96
96
  const scroller = bar.nextElementSibling as HTMLElement;
97
97
  expect(scroller.className).not.toContain('layout-trigger');
@@ -114,77 +114,16 @@ describe('PrivateContent top bar', () => {
114
114
  expect(scroller.className).toMatch(/\boverflow-y-auto\b/);
115
115
  });
116
116
 
117
- describe('phone auto-hide', () => {
118
- function mountPhone(isPhone = true) {
117
+ it('never hides on scroll, on a phone or on desktop', () => {
118
+ for (const isPhone of [true, false]) {
119
119
  phone = isPhone;
120
- // Deferred like a real frame: useScroll's store expects the id to be assigned before the callback runs.
121
- let id = 0;
122
- vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
123
- void Promise.resolve().then(() => cb(0));
124
- return ++id;
125
- });
126
- const el = render(false, 'default', 'Acme');
127
- const bar = el.querySelector('[data-sidebar="topbar"]') as HTMLElement;
120
+ const bar = render(false, 'default', 'Acme').querySelector('[data-sidebar="topbar"]') as HTMLElement;
128
121
  const scroller = bar.nextElementSibling as HTMLElement;
129
- Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 3000 });
130
- Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 800 });
131
- const scrollTo = (top: number) =>
132
- act(async () => {
133
- scroller.scrollTop = top;
134
- scroller.dispatchEvent(new Event('scroll'));
135
- await Promise.resolve();
136
- await Promise.resolve();
137
- });
138
- return { el, bar, scroller, scrollTo };
122
+ scroller.scrollTop = 400;
123
+ scroller.dispatchEvent(new Event('scroll'));
124
+ expect(bar.hasAttribute('data-hidden')).toBe(false);
125
+ expect(bar.className).not.toContain('translate');
139
126
  }
140
- afterEach(() => vi.unstubAllGlobals());
141
-
142
- it('hides on scroll down, shows on scroll up and at the top', async () => {
143
- const { bar, scrollTo } = mountPhone();
144
- expect(bar.getAttribute('data-hidden')).toBe('false');
145
- await scrollTo(120);
146
- await scrollTo(200);
147
- expect(bar.getAttribute('data-hidden')).toBe('true');
148
- await scrollTo(180);
149
- await scrollTo(150);
150
- expect(bar.getAttribute('data-hidden')).toBe('false');
151
- await scrollTo(300);
152
- await scrollTo(400);
153
- expect(bar.getAttribute('data-hidden')).toBe('true');
154
- await scrollTo(0);
155
- expect(bar.getAttribute('data-hidden')).toBe('false');
156
- });
157
-
158
- it('desktop (sidebar collapsed): the bar is static and never hides', async () => {
159
- const { bar, scrollTo } = mountPhone(false);
160
- await scrollTo(120);
161
- await scrollTo(400);
162
- expect(bar.getAttribute('data-hidden')).toBe('false');
163
- });
164
-
165
- it('stays visible while the drawer is open', async () => {
166
- const { bar, scrollTo } = mountPhone();
167
- act(() => (bar.querySelector('[data-sidebar="trigger"]') as HTMLElement).click());
168
- await scrollTo(120);
169
- await scrollTo(400);
170
- expect(bar.getAttribute('data-hidden')).toBe('false');
171
- });
172
-
173
- it('stays visible while a field has focus', async () => {
174
- const { el, bar, scrollTo } = mountPhone();
175
- act(() => (el.querySelector('input') as HTMLInputElement).focus());
176
- await scrollTo(120);
177
- await scrollTo(400);
178
- expect(bar.getAttribute('data-hidden')).toBe('false');
179
- });
180
-
181
- it('never hides when the content is not scrollable', async () => {
182
- const { bar, scroller, scrollTo } = mountPhone();
183
- Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 800 });
184
- await scrollTo(120);
185
- await scrollTo(400);
186
- expect(bar.getAttribute('data-hidden')).toBe('false');
187
- });
188
127
  });
189
128
 
190
129
  describe('topBar content', () => {
@@ -2,8 +2,8 @@
2
2
  * Private layout main column: the scroll surface, plus ONE top bar whenever the sidebar is not visible: always on a
3
3
  * phone (below md), on desktop while the sidebar is collapsed. Same markup and props at every width: sidebar toggle,
4
4
  * title, actions, account avatar. Height `--layout-bar-height` = safe-top + 3rem; it overlays the column and the
5
- * content has that height as top padding, so pages need to know nothing about it. Below md it slides away on
6
- * scroll-down (same `useNavbarScroll` policy as PublicLayout, driven by the content scroller); on desktop it is static.
5
+ * content has that height as top padding, so pages need to know nothing about it. It never hides on scroll (owner decision,
6
+ * 2026-11-05: it got in the way): static at every width.
7
7
  * `--layout-bar-height` is `0px` only when no bar is shown (docked sidebar, or no sidebar at all).
8
8
  */
9
9
 
@@ -17,7 +17,6 @@ import { cn } from '@djangocfg/ui-core/lib';
17
17
  import type { LayoutVisualConfig } from '../../types';
18
18
  import type { HeaderConfig } from '../types';
19
19
  import { LAYOUT_BAR_HEIGHT } from '../geometry';
20
- import { useNavbarScroll } from '../../PublicLayout/hooks/useNavbarScroll';
21
20
  import { PrivateSidebarTrigger } from './PrivateSidebarTrigger';
22
21
  import { PrivateTopBarAccount } from './PrivateTopBarAccount';
23
22
 
@@ -77,22 +76,9 @@ export function PrivateContent({
77
76
  scrollRef.current?.scrollTo?.({ top: 0 });
78
77
  }, [pathname]);
79
78
 
80
- const { isMobile, state, openMobile } = useSidebar();
79
+ const { isMobile, state } = useSidebar();
81
80
 
82
81
  const showBar = hasSidebar && (isMobile || state === 'collapsed');
83
- const mobileBar = showBar && isMobile;
84
-
85
- // Auto-hide: the content scroller (the document never scrolls) drives the shared navbar policy. The bar stays put
86
- // while the drawer is open or a field has focus (iOS keyboard), and when there is nothing to scroll.
87
- const { hidden: scrolledAway } = useNavbarScroll({ hideNavOnScroll: mobileBar, target: scrollRef, respectReducedMotion: false });
88
- const [fieldFocused, setFieldFocused] = React.useState(false);
89
- const onFocusChange = (e: React.FocusEvent) => {
90
- const el = e.target as HTMLElement;
91
- setFieldFocused(
92
- e.type === 'focus' && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable),
93
- );
94
- };
95
- const barHidden = mobileBar && scrolledAway && !openMobile && !fieldFocused;
96
82
 
97
83
  const topPadding = showBar
98
84
  ? 'pt-[calc(var(--layout-bar-height)+1rem)]'
@@ -122,8 +108,7 @@ export function PrivateContent({
122
108
  {showBar ? (
123
109
  <div
124
110
  data-sidebar="topbar"
125
- data-hidden={barHidden ? 'true' : 'false'}
126
- className="absolute inset-x-0 top-0 z-20 box-border flex h-[var(--layout-bar-height)] shrink-0 items-center gap-1 transition-transform duration-200 ease-out motion-reduce:transition-none data-[hidden=true]:-translate-y-full border-b border-border bg-background pl-[calc(var(--safe-left,env(safe-area-inset-left,0px))+0.75rem)] pr-[calc(var(--safe-right,env(safe-area-inset-right,0px))+0.75rem)] pt-[var(--safe-top,env(safe-area-inset-top,0px))]"
111
+ className="absolute inset-x-0 top-0 z-20 box-border flex h-[var(--layout-bar-height)] shrink-0 items-center gap-1 border-b border-border bg-background pl-[calc(var(--safe-left,env(safe-area-inset-left,0px))+0.75rem)] pr-[calc(var(--safe-right,env(safe-area-inset-right,0px))+0.75rem)] pt-[var(--safe-top,env(safe-area-inset-top,0px))]"
127
112
  >
128
113
  <PrivateSidebarTrigger className="text-muted-foreground hover:text-foreground" />
129
114
  <span className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight">
@@ -147,7 +132,7 @@ export function PrivateContent({
147
132
  ) : null}
148
133
  </div>
149
134
  ) : null}
150
- <div ref={scrollRef} className={scrollAreaClass} onFocusCapture={onFocusChange} onBlurCapture={onFocusChange}>
135
+ <div ref={scrollRef} className={scrollAreaClass}>
151
136
  {innerWidthClass ? <div className={innerWidthClass}>{children}</div> : children}
152
137
  </div>
153
138
  </div>