Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions entry_types/scrolled/config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ de:
sparen, wird die Navigation im Phone-Layout beim
Scrollen dennoch immer ausgeblendet.
label: Im Desktop-Layout fixieren
firstBackdropBelowNavigation:
inline_help: |
Positioniert das Hintergrundbild der ersten Sektion
unterhalb der Navigationsleiste statt dahinter.
inline_help_disabled: |
Positioniert das Hintergrundbild der ersten Sektion
unterhalb der Navigationsleiste statt dahinter. Nur
verfügbar, wenn die Navigation fixiert ist.
label: Hintergrund unterhalb beginnen
hideSharingButton:
label: Share-Button ausblenden
hideToggleMuteButton:
Expand Down
9 changes: 9 additions & 0 deletions entry_types/scrolled/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ en:
navigation is still always hidden when the page is
scrolled in phone layout.
label: Keep expanded in desktop layout
firstBackdropBelowNavigation:
inline_help: |
Position the first section's backdrop image below the
navigation bar instead of behind it.
inline_help_disabled: |
Position the first section's backdrop image below the
navigation bar instead of behind it. Only available
when navigation is set to stay expanded.
label: Start backdrop below
hideSharingButton:
label: Hide share button
hideToggleMuteButton:
Expand Down
66 changes: 66 additions & 0 deletions entry_types/scrolled/doc/creating_widget_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,69 @@ entry_type_config.themes.register(:my_custom_theme,
}
}
```

## Presence Providers

When registering a widget type, you can pass a `PresenceProvider`
component. When the widget is present in an entry, this component
wraps the entry content. This allows widgets to define CSS custom
properties that cascade down to sections and backdrops, and to provide
React context that the widget component can consume.

The `PresenceProvider` receives the widget's `configuration` and
`children`:

``` javascript
frontend.widgetTypes.register('myNavigation', {
component: MyNavigation,
PresenceProvider: function({configuration, children}) {
const [expanded, setExpanded] = useState(true);

const className = classNames(styles.presence, {
[styles.expanded]: expanded
});

return (
<MyNavigationContext.Provider value={{expanded, setExpanded}}>
<div className={className}>
{children}
</div>
</MyNavigationContext.Provider>
);
}
});
```

### Widget Margin Custom Properties

Navigation widgets that are fixed at the top of the viewport can set
the following CSS custom properties via a presence provider to ensure
backdrops and full height sections are positioned correctly below the
navigation bar:

* `--widget-margin-top-max`: Maximum height the widget occupies. Used
to add padding to the first section so that content starts below the
navigation bar.

* `--widget-margin-top-min`: Minimum height the widget occupies when
scrolled. Used to position sticky backdrops below the navigation bar
and to calculate the height of full viewport sections.

For example, a navigation bar that is always visible could define:

``` css
.presence {
--widget-margin-top-max: 60px;
--widget-margin-top-min: 60px;
}
```

A navigation bar that hides on scroll but keeps a progress bar visible
could use different values:

``` css
.presence {
--widget-margin-top-max: 60px;
--widget-margin-top-min: 5px;
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
bottom: 0;
right: 0;
}

:root {
--theme-widget-margin-top: 58px;
}
</style>

<script>
Expand Down
40 changes: 40 additions & 0 deletions entry_types/scrolled/package/spec/entryState/widgets-spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
useWidget,
useActiveWidgets,
watchCollections
} from 'entryState';

Expand Down Expand Up @@ -99,3 +100,42 @@ describe('useWidget', () => {
expect(widget).toBeUndefined();
});
});

describe('useActiveWidgets', () => {
it('returns widgets with typeName from seed', () => {
const {result} = renderHookInEntry(
() => useActiveWidgets(),
{
seed: {
widgets: [
{typeName: 'navWidget', role: 'header', configuration: {fixed: true}},
{typeName: 'footerWidget', role: 'footer', configuration: {}}
]
}
}
);

expect(result.current).toMatchObject([
{typeName: 'navWidget', role: 'header', configuration: {fixed: true}},
{typeName: 'footerWidget', role: 'footer', configuration: {}}
]);
});

it('filters out widgets with blank typeName', () => {
const {result} = renderHookInEntry(
() => useActiveWidgets(),
{
seed: {
widgets: [
{typeName: 'navWidget', role: 'header', configuration: {}},
{typeName: null, role: 'consent', configuration: {}}
]
}
}
);

expect(result.current).toMatchObject([
{typeName: 'navWidget', role: 'header', configuration: {}}
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import React, {useContext, createContext} from 'react';

import {frontend} from 'frontend';

import {renderEntry, usePageObjects} from 'support/pageObjects';
import '@testing-library/jest-dom/extend-expect'

describe('widget PresenceProvider', () => {
usePageObjects();

it('wraps entry content with PresenceProvider component', () => {
frontend.widgetTypes.register('providerWidget', {
component: function() { return <div>Widget</div>; },
PresenceProvider: function({children}) {
return <div data-testid="presence-wrapper">{children}</div>;
}
});

const {getByTestId} = renderEntry({
seed: {
widgets: [{
typeName: 'providerWidget',
role: 'header'
}]
}
});

expect(getByTestId('presence-wrapper')).toBeInTheDocument();
});

it('does not render PresenceProvider when widget is not active', () => {
frontend.widgetTypes.register('providerWidget2', {
component: function() { return <div>Widget</div>; },
PresenceProvider: function({children}) {
return <div data-testid="presence-wrapper-2">{children}</div>;
}
});

const {queryByTestId} = renderEntry({
seed: {
widgets: []
}
});

expect(queryByTestId('presence-wrapper-2')).not.toBeInTheDocument();
});

it('passes configuration to PresenceProvider', () => {
frontend.widgetTypes.register('configuredProviderWidget', {
component: function() { return <div>Widget</div>; },
PresenceProvider: function({configuration, children}) {
return (
<div data-testid="configured-wrapper" data-fixed={String(configuration.fixed)}>
{children}
</div>
);
}
});

const {getByTestId} = renderEntry({
seed: {
widgets: [{
typeName: 'configuredProviderWidget',
role: 'header',
configuration: {fixed: true}
}]
}
});

expect(getByTestId('configured-wrapper')).toHaveAttribute('data-fixed', 'true');
});

it('nests multiple PresenceProviders when multiple widgets are active', () => {
frontend.widgetTypes.register('headerProviderWidget', {
component: function() { return <div>Header</div>; },
PresenceProvider: function({children}) {
return <div data-testid="header-wrapper">{children}</div>;
}
});

frontend.widgetTypes.register('footerProviderWidget', {
component: function() { return <div>Footer</div>; },
PresenceProvider: function({children}) {
return <div data-testid="footer-wrapper">{children}</div>;
}
});

const {getByTestId} = renderEntry({
seed: {
widgets: [
{typeName: 'headerProviderWidget', role: 'header'},
{typeName: 'footerProviderWidget', role: 'footer'}
]
}
});

expect(getByTestId('header-wrapper')).toBeInTheDocument();
expect(getByTestId('footer-wrapper')).toBeInTheDocument();
});

it('allows PresenceProvider to provide context consumed by widget component', () => {
const TestContext = createContext(null);

frontend.widgetTypes.register('contextProviderWidget', {
component: function() {
const value = useContext(TestContext);

Check failure on line 106 in entry_types/scrolled/package/spec/frontend/features/widgetPresenceProvider-spec.js

View workflow job for this annotation

GitHub Actions / lint

React Hook "useContext" is called in function "component" which is neither a React function component or a custom React Hook function.
return <div data-testid="context-consumer">{value}</div>;
},
PresenceProvider: function({children}) {
return (
<TestContext.Provider value="from-presence">
{children}
</TestContext.Provider>
);
}
});

const {getByTestId} = renderEntry({
seed: {
widgets: [{
typeName: 'contextProviderWidget',
role: 'header'
}]
}
});

expect(getByTestId('context-consumer')).toHaveTextContent('from-presence');
});

it('does not fail when widget type has no PresenceProvider', () => {
frontend.widgetTypes.register('noProviderWidget', {
component: function() { return <div data-testid="no-provider">Simple</div>; }
});

const {getByTestId} = renderEntry({
seed: {
widgets: [{typeName: 'noProviderWidget', role: 'header'}]
}
});

expect(getByTestId('no-provider')).toBeInTheDocument();
});
});
3 changes: 2 additions & 1 deletion entry_types/scrolled/package/spec/support/pageObjects.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ import {act, fireEvent, queryHelpers, queries, within} from '@testing-library/re
import {useFakeTranslations} from 'pageflow/testHelpers';
import {simulateScrollingIntoView} from './fakeIntersectionObserver';

export function renderEntry({seed, consent, isStaticPreview} = {}) {
export function renderEntry({seed, consent, isStaticPreview, phonePlatform} = {}) {
const result = renderInEntry(<Entry />, {
seed,
consent,
phonePlatform,
wrapper: isStaticPreview ? StaticPreview : null,
queries: {...queries, ...pageObjectQueries}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@ import React from 'react';

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

import styles from 'widgets/defaultNavigation/DefaultNavigation.module.css';

import {useFakeTranslations} from 'pageflow/testHelpers';
import {renderInEntry} from 'pageflow-scrolled/testHelpers';
import {act} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';

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

afterEach(() => jest.restoreAllMocks());

it('does not have style attribute on header by default', () => {
const {container} = renderInEntry(
<DefaultNavigation configuration={{}} />
Expand All @@ -30,40 +29,23 @@ describe('DefaultNavigation - Styling', () => {
);
});

it('toggles data-default-navigation-expanded attribute based on scroll direction', () => {
// Mock window.scrollY and getBoundingClientRect to simulate scroll positions
Object.defineProperty(window, 'scrollY', {
writable: true,
value: 0
});

jest.spyOn(document.body, 'getBoundingClientRect').mockImplementation(() => ({
top: -window.scrollY,
left: 0,
right: 1024,
bottom: 768 - window.scrollY,
width: 1024,
height: 768
}));

renderInEntry(
it('has translucent surface by default', () => {
const {container} = renderInEntry(
<DefaultNavigation configuration={{}} />
);

expect(document.documentElement).toHaveAttribute('data-default-navigation-expanded');

act(() => {
window.scrollY = 100;
window.dispatchEvent(new Event('scroll'));
});

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

act(() => {
window.scrollY = 50;
window.dispatchEvent(new Event('scroll'));
});
it('has opaque surface when firstBackdropBelowNavigation is set', () => {
const {container} = renderInEntry(
<DefaultNavigation configuration={{firstBackdropBelowNavigation: true}} />
);

expect(document.documentElement).toHaveAttribute('data-default-navigation-expanded');
const wrapper = container.querySelector('header > div');
expect(wrapper).toHaveClass(styles.opaqueSurface);
expect(wrapper).not.toHaveClass(styles.translucentSurface);
});
});
Loading
Loading