Skip to content

Commit 12fbb7c

Browse files
authored
Merge pull request #2352 from tf/below-nav
Add option to position first backdrop below nav bar
2 parents f7a4e36 + 4248cbf commit 12fbb7c

32 files changed

Lines changed: 610 additions & 116 deletions

entry_types/scrolled/config/locales/de.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ de:
3333
sparen, wird die Navigation im Phone-Layout beim
3434
Scrollen dennoch immer ausgeblendet.
3535
label: Im Desktop-Layout fixieren
36+
firstBackdropBelowNavigation:
37+
inline_help: |
38+
Positioniert das Hintergrundbild der ersten Sektion
39+
unterhalb der Navigationsleiste statt dahinter.
40+
inline_help_disabled: |
41+
Positioniert das Hintergrundbild der ersten Sektion
42+
unterhalb der Navigationsleiste statt dahinter. Nur
43+
verfügbar, wenn die Navigation fixiert ist.
44+
label: Hintergrund unterhalb beginnen
3645
hideSharingButton:
3746
label: Share-Button ausblenden
3847
hideToggleMuteButton:

entry_types/scrolled/config/locales/en.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ en:
3333
navigation is still always hidden when the page is
3434
scrolled in phone layout.
3535
label: Keep expanded in desktop layout
36+
firstBackdropBelowNavigation:
37+
inline_help: |
38+
Position the first section's backdrop image below the
39+
navigation bar instead of behind it.
40+
inline_help_disabled: |
41+
Position the first section's backdrop image below the
42+
navigation bar instead of behind it. Only available
43+
when navigation is set to stay expanded.
44+
label: Start backdrop below
3645
hideSharingButton:
3746
label: Hide share button
3847
hideToggleMuteButton:

entry_types/scrolled/doc/creating_widget_types.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,69 @@ entry_type_config.themes.register(:my_custom_theme,
284284
}
285285
}
286286
```
287+
288+
## Presence Providers
289+
290+
When registering a widget type, you can pass a `PresenceProvider`
291+
component. When the widget is present in an entry, this component
292+
wraps the entry content. This allows widgets to define CSS custom
293+
properties that cascade down to sections and backdrops, and to provide
294+
React context that the widget component can consume.
295+
296+
The `PresenceProvider` receives the widget's `configuration` and
297+
`children`:
298+
299+
``` javascript
300+
frontend.widgetTypes.register('myNavigation', {
301+
component: MyNavigation,
302+
PresenceProvider: function({configuration, children}) {
303+
const [expanded, setExpanded] = useState(true);
304+
305+
const className = classNames(styles.presence, {
306+
[styles.expanded]: expanded
307+
});
308+
309+
return (
310+
<MyNavigationContext.Provider value={{expanded, setExpanded}}>
311+
<div className={className}>
312+
{children}
313+
</div>
314+
</MyNavigationContext.Provider>
315+
);
316+
}
317+
});
318+
```
319+
320+
### Widget Margin Custom Properties
321+
322+
Navigation widgets that are fixed at the top of the viewport can set
323+
the following CSS custom properties via a presence provider to ensure
324+
backdrops and full height sections are positioned correctly below the
325+
navigation bar:
326+
327+
* `--widget-margin-top-max`: Maximum height the widget occupies. Used
328+
to add padding to the first section so that content starts below the
329+
navigation bar.
330+
331+
* `--widget-margin-top-min`: Minimum height the widget occupies when
332+
scrolled. Used to position sticky backdrops below the navigation bar
333+
and to calculate the height of full viewport sections.
334+
335+
For example, a navigation bar that is always visible could define:
336+
337+
``` css
338+
.presence {
339+
--widget-margin-top-max: 60px;
340+
--widget-margin-top-min: 60px;
341+
}
342+
```
343+
344+
A navigation bar that hides on scroll but keeps a progress bar visible
345+
could use different values:
346+
347+
``` css
348+
.presence {
349+
--widget-margin-top-max: 60px;
350+
--widget-margin-top-min: 5px;
351+
}
352+
```

entry_types/scrolled/package/.storybook/preview-head.html.template

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
bottom: 0;
3030
right: 0;
3131
}
32+
33+
:root {
34+
--theme-widget-margin-top: 58px;
35+
}
3236
</style>
3337

3438
<script>

entry_types/scrolled/package/spec/entryState/widgets-spec.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
useWidget,
3+
useActiveWidgets,
34
watchCollections
45
} from 'entryState';
56

@@ -99,3 +100,42 @@ describe('useWidget', () => {
99100
expect(widget).toBeUndefined();
100101
});
101102
});
103+
104+
describe('useActiveWidgets', () => {
105+
it('returns widgets with typeName from seed', () => {
106+
const {result} = renderHookInEntry(
107+
() => useActiveWidgets(),
108+
{
109+
seed: {
110+
widgets: [
111+
{typeName: 'navWidget', role: 'header', configuration: {fixed: true}},
112+
{typeName: 'footerWidget', role: 'footer', configuration: {}}
113+
]
114+
}
115+
}
116+
);
117+
118+
expect(result.current).toMatchObject([
119+
{typeName: 'navWidget', role: 'header', configuration: {fixed: true}},
120+
{typeName: 'footerWidget', role: 'footer', configuration: {}}
121+
]);
122+
});
123+
124+
it('filters out widgets with blank typeName', () => {
125+
const {result} = renderHookInEntry(
126+
() => useActiveWidgets(),
127+
{
128+
seed: {
129+
widgets: [
130+
{typeName: 'navWidget', role: 'header', configuration: {}},
131+
{typeName: null, role: 'consent', configuration: {}}
132+
]
133+
}
134+
}
135+
);
136+
137+
expect(result.current).toMatchObject([
138+
{typeName: 'navWidget', role: 'header', configuration: {}}
139+
]);
140+
});
141+
});
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import React, {useContext, createContext} from 'react';
2+
3+
import {frontend} from 'frontend';
4+
5+
import {renderEntry, usePageObjects} from 'support/pageObjects';
6+
import '@testing-library/jest-dom/extend-expect'
7+
8+
describe('widget PresenceProvider', () => {
9+
usePageObjects();
10+
11+
it('wraps entry content with PresenceProvider component', () => {
12+
frontend.widgetTypes.register('providerWidget', {
13+
component: function() { return <div>Widget</div>; },
14+
presenceProvider: function({children}) {
15+
return <div data-testid="presence-wrapper">{children}</div>;
16+
}
17+
});
18+
19+
const {getByTestId} = renderEntry({
20+
seed: {
21+
widgets: [{
22+
typeName: 'providerWidget',
23+
role: 'header'
24+
}]
25+
}
26+
});
27+
28+
expect(getByTestId('presence-wrapper')).toBeInTheDocument();
29+
});
30+
31+
it('does not render PresenceProvider when widget is not active', () => {
32+
frontend.widgetTypes.register('providerWidget2', {
33+
component: function() { return <div>Widget</div>; },
34+
presenceProvider: function({children}) {
35+
return <div data-testid="presence-wrapper-2">{children}</div>;
36+
}
37+
});
38+
39+
const {queryByTestId} = renderEntry({
40+
seed: {
41+
widgets: []
42+
}
43+
});
44+
45+
expect(queryByTestId('presence-wrapper-2')).not.toBeInTheDocument();
46+
});
47+
48+
it('passes configuration to PresenceProvider', () => {
49+
frontend.widgetTypes.register('configuredProviderWidget', {
50+
component: function() { return <div>Widget</div>; },
51+
presenceProvider: function({configuration, children}) {
52+
return (
53+
<div data-testid="configured-wrapper" data-fixed={String(configuration.fixed)}>
54+
{children}
55+
</div>
56+
);
57+
}
58+
});
59+
60+
const {getByTestId} = renderEntry({
61+
seed: {
62+
widgets: [{
63+
typeName: 'configuredProviderWidget',
64+
role: 'header',
65+
configuration: {fixed: true}
66+
}]
67+
}
68+
});
69+
70+
expect(getByTestId('configured-wrapper')).toHaveAttribute('data-fixed', 'true');
71+
});
72+
73+
it('nests multiple PresenceProviders when multiple widgets are active', () => {
74+
frontend.widgetTypes.register('headerProviderWidget', {
75+
component: function() { return <div>Header</div>; },
76+
presenceProvider: function({children}) {
77+
return <div data-testid="header-wrapper">{children}</div>;
78+
}
79+
});
80+
81+
frontend.widgetTypes.register('footerProviderWidget', {
82+
component: function() { return <div>Footer</div>; },
83+
presenceProvider: function({children}) {
84+
return <div data-testid="footer-wrapper">{children}</div>;
85+
}
86+
});
87+
88+
const {getByTestId} = renderEntry({
89+
seed: {
90+
widgets: [
91+
{typeName: 'headerProviderWidget', role: 'header'},
92+
{typeName: 'footerProviderWidget', role: 'footer'}
93+
]
94+
}
95+
});
96+
97+
expect(getByTestId('header-wrapper')).toBeInTheDocument();
98+
expect(getByTestId('footer-wrapper')).toBeInTheDocument();
99+
});
100+
101+
it('allows PresenceProvider to provide context consumed by widget component', () => {
102+
const TestContext = createContext(null);
103+
104+
frontend.widgetTypes.register('contextProviderWidget', {
105+
component: function Component() {
106+
const value = useContext(TestContext);
107+
return <div data-testid="context-consumer">{value}</div>;
108+
},
109+
presenceProvider: function({children}) {
110+
return (
111+
<TestContext.Provider value="from-presence">
112+
{children}
113+
</TestContext.Provider>
114+
);
115+
}
116+
});
117+
118+
const {getByTestId} = renderEntry({
119+
seed: {
120+
widgets: [{
121+
typeName: 'contextProviderWidget',
122+
role: 'header'
123+
}]
124+
}
125+
});
126+
127+
expect(getByTestId('context-consumer')).toHaveTextContent('from-presence');
128+
});
129+
130+
it('does not fail when widget type has no PresenceProvider', () => {
131+
frontend.widgetTypes.register('noProviderWidget', {
132+
component: function() { return <div data-testid="no-provider">Simple</div>; }
133+
});
134+
135+
const {getByTestId} = renderEntry({
136+
seed: {
137+
widgets: [{typeName: 'noProviderWidget', role: 'header'}]
138+
}
139+
});
140+
141+
expect(getByTestId('no-provider')).toBeInTheDocument();
142+
});
143+
});

entry_types/scrolled/package/spec/support/pageObjects.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ import {act, fireEvent, queryHelpers, queries, within} from '@testing-library/re
1919
import {useFakeTranslations} from 'pageflow/testHelpers';
2020
import {simulateScrollingIntoView} from './fakeIntersectionObserver';
2121

22-
export function renderEntry({seed, consent, isStaticPreview} = {}) {
22+
export function renderEntry({seed, consent, isStaticPreview, phonePlatform} = {}) {
2323
const result = renderInEntry(<Entry />, {
2424
seed,
2525
consent,
26+
phonePlatform,
2627
wrapper: isStaticPreview ? StaticPreview : null,
2728
queries: {...queries, ...pageObjectQueries}
2829
});

entry_types/scrolled/package/spec/widgets/defaultNavigation/DefaultNavigation/styling-spec.js

Lines changed: 15 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@ import React from 'react';
22

33
import {DefaultNavigation} from 'widgets/defaultNavigation/DefaultNavigation';
44

5+
import styles from 'widgets/defaultNavigation/DefaultNavigation.module.css';
6+
57
import {useFakeTranslations} from 'pageflow/testHelpers';
68
import {renderInEntry} from 'pageflow-scrolled/testHelpers';
7-
import {act} from '@testing-library/react';
89
import '@testing-library/jest-dom/extend-expect';
910

1011
describe('DefaultNavigation - Styling', () => {
1112
useFakeTranslations({});
1213

13-
afterEach(() => jest.restoreAllMocks());
14-
1514
it('does not have style attribute on header by default', () => {
1615
const {container} = renderInEntry(
1716
<DefaultNavigation configuration={{}} />
@@ -30,40 +29,23 @@ describe('DefaultNavigation - Styling', () => {
3029
);
3130
});
3231

33-
it('toggles data-default-navigation-expanded attribute based on scroll direction', () => {
34-
// Mock window.scrollY and getBoundingClientRect to simulate scroll positions
35-
Object.defineProperty(window, 'scrollY', {
36-
writable: true,
37-
value: 0
38-
});
39-
40-
jest.spyOn(document.body, 'getBoundingClientRect').mockImplementation(() => ({
41-
top: -window.scrollY,
42-
left: 0,
43-
right: 1024,
44-
bottom: 768 - window.scrollY,
45-
width: 1024,
46-
height: 768
47-
}));
48-
49-
renderInEntry(
32+
it('has translucent surface by default', () => {
33+
const {container} = renderInEntry(
5034
<DefaultNavigation configuration={{}} />
5135
);
5236

53-
expect(document.documentElement).toHaveAttribute('data-default-navigation-expanded');
54-
55-
act(() => {
56-
window.scrollY = 100;
57-
window.dispatchEvent(new Event('scroll'));
58-
});
59-
60-
expect(document.documentElement).not.toHaveAttribute('data-default-navigation-expanded');
37+
const wrapper = container.querySelector('header > div');
38+
expect(wrapper).toHaveClass(styles.translucentSurface);
39+
expect(wrapper).not.toHaveClass(styles.opaqueSurface);
40+
});
6141

62-
act(() => {
63-
window.scrollY = 50;
64-
window.dispatchEvent(new Event('scroll'));
65-
});
42+
it('has opaque surface when firstBackdropBelowNavigation is set', () => {
43+
const {container} = renderInEntry(
44+
<DefaultNavigation configuration={{firstBackdropBelowNavigation: true}} />
45+
);
6646

67-
expect(document.documentElement).toHaveAttribute('data-default-navigation-expanded');
47+
const wrapper = container.querySelector('header > div');
48+
expect(wrapper).toHaveClass(styles.opaqueSurface);
49+
expect(wrapper).not.toHaveClass(styles.translucentSurface);
6850
});
6951
});

0 commit comments

Comments
 (0)