Skip to content

Commit

Permalink
♻️ [#36] Set up styling for scrollable progress indicator on mobile
Browse files Browse the repository at this point in the history
This is quite involved as there are some styling edge cases to consider:

* form title that overflows and is truncated with ellipsis - if you're not
  careful, this can cause a horizontal scrollbar
* form title that is long and the main content being scrollable - this can also
  cause a small horizontal scrollbar due to the ellipsis situation
* the progress indicator content itself should be scrollable, independent from
  the main content
* possibly there is an external header/footer that may take up some vertical
  space, which limits the amount of vertical space available for our progress
  indicator

The implementation now passes a ref to the button element in React, which
allows us to calculate the height of the button (our 'menu' bar) AND its offset
to the top of the page, in case there is another external UI element taking
space. We then allocate a maximum block size (= vertical space) as the
dynamic viewport block (100dvb, dynamic taking into account the address bar
that can dissapear on actual mobile devices) minues the vertical/block spacing
reserved for the button and possible third party content.

When scrolling down, our button may overlay the third party content (due to the
position: sticky) and this frees up a little bit of space below the progress
indicator - this is deliberate. Users are allowed to interact with buttons
below it and simplifies us having to track more things in React.
  • Loading branch information
sergei-maertens committed Nov 27, 2023
1 parent 3b31fbb commit e0ab43e
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 112 deletions.
43 changes: 37 additions & 6 deletions src/components/App.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default {
completed: false,
},
],
showExternalHeader: false,
},
argTypes: {
submissionAllowed: {
Expand Down Expand Up @@ -83,7 +84,7 @@ export default {
},
};

const Wrapper = ({form}) => {
const Wrapper = ({form, showExternalHeader}) => {
const routes = [
{
path: '*',
Expand All @@ -97,6 +98,9 @@ const Wrapper = ({form}) => {
});
return (
<FormContext.Provider value={form}>
{showExternalHeader && (
<header style={{padding: '10px', textAlign: 'center'}}>External header</header>
)}
<RouterProvider router={router} />
</FormContext.Provider>
);
Expand All @@ -111,7 +115,7 @@ const render = args => {
hideNonApplicableSteps: args['hideNonApplicableSteps'],
steps: args['steps'],
});
return <Wrapper form={form} />;
return <Wrapper form={form} showExternalHeader={args.showExternalHeader} />;
};

export const Default = {
Expand Down Expand Up @@ -210,6 +214,7 @@ export const ActiveSubmission = {
export const SeveralStepsInMobileViewport = {
render,
args: {
showExternalHeader: true,
name: 'A rather long form name that overflows on mobile',
steps: [
{
Expand Down Expand Up @@ -317,7 +322,7 @@ export const SeveralStepsInMobileViewport = {
isApplicable: true,
},
{
uuid: '03657dc1-6bb1-49cf-8113-4b663981b70f',
uuid: '4d4767b6-a3a4-4519-a1d3-f81e15bf829c',
slug: 'step-9',
formDefinition: 'Step 9',
index: 8,
Expand All @@ -326,11 +331,11 @@ export const SeveralStepsInMobileViewport = {
saveText: {resolved: 'Save', value: ''},
nextText: {resolved: 'Next', value: ''},
},
url: `${BASE_URL}forms/mock/steps/03657dc1-6bb1-49cf-8113-4b663981b70f`,
url: `${BASE_URL}forms/mock/steps/4d4767b6-a3a4-4519-a1d3-f81e15bf829c`,
isApplicable: true,
},
{
uuid: '03657dc1-6bb1-49cf-8113-4b663981b70f',
uuid: '9166d9b7-baa9-429a-a19e-c0c88f2fdaa8',
slug: 'step-10',
formDefinition: 'Step 10',
index: 9,
Expand All @@ -339,7 +344,33 @@ export const SeveralStepsInMobileViewport = {
saveText: {resolved: 'Save', value: ''},
nextText: {resolved: 'Next', value: ''},
},
url: `${BASE_URL}forms/mock/steps/03657dc1-6bb1-49cf-8113-4b663981b70f`,
url: `${BASE_URL}forms/mock/steps/9166d9b7-baa9-429a-a19e-c0c88f2fdaa8`,
isApplicable: true,
},
{
uuid: 'c5eb8263-39e7-4e8c-a6f4-77278f50ee52',
slug: 'step-11',
formDefinition: 'Step 11',
index: 10,
literals: {
previousText: {resolved: 'Previous', value: ''},
saveText: {resolved: 'Save', value: ''},
nextText: {resolved: 'Next', value: ''},
},
url: `${BASE_URL}forms/mock/steps/c5eb8263-39e7-4e8c-a6f4-77278f50ee52`,
isApplicable: true,
},
{
uuid: '4f79f369-7aca-4346-9d91-5d5e628b7fb0',
slug: 'step-12',
formDefinition: 'Step 12',
index: 11,
literals: {
previousText: {resolved: 'Previous', value: ''},
saveText: {resolved: 'Save', value: ''},
nextText: {resolved: 'Next', value: ''},
},
url: `${BASE_URL}forms/mock/steps/4f79f369-7aca-4346-9d91-5d5e628b7fb0`,
isApplicable: true,
},
],
Expand Down
3 changes: 2 additions & 1 deletion src/components/Card.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ const Card = ({
captionComponent,
blockClassName = 'card',
modifiers = [],
...props
}) => {
const className = getBEMClassName(blockClassName, modifiers);
return (
<div className={className}>
<div className={className} {...props}>
{/* Emit header/title only if there is one */}
{title ? (
<CardTitle
Expand Down
50 changes: 24 additions & 26 deletions src/components/ProgressIndicator/MobileButton.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
import PropTypes from 'prop-types';
import {forwardRef} from 'react';

import FAIcon from 'components/FAIcon';

const MobileButton = ({
ariaMobileIconLabel,
accessibleToggleStepsLabel,
formTitle,
expanded,
onExpandClick,
}) => {
return (
<button
className="openforms-progress-indicator__mobile-header"
aria-pressed={expanded ? 'true' : 'false'}
onClick={onExpandClick}
>
<FAIcon
icon={expanded ? 'chevron-up' : 'chevron-down'}
modifiers={['normal']}
aria-label={ariaMobileIconLabel}
/>
<span
className="openforms-progress-indicator__form-title"
aria-label={accessibleToggleStepsLabel}
const MobileButton = forwardRef(
({ariaMobileIconLabel, accessibleToggleStepsLabel, formTitle, expanded, onExpandClick}, ref) => {
return (
<button
ref={ref}
className="openforms-progress-indicator__mobile-header"
aria-pressed={expanded ? 'true' : 'false'}
onClick={onExpandClick}
>
{formTitle}
</span>
</button>
);
};
<FAIcon
icon={expanded ? 'chevron-up' : 'chevron-down'}
modifiers={['normal']}
aria-label={ariaMobileIconLabel}
/>
<span
className="openforms-progress-indicator__form-title"
aria-label={accessibleToggleStepsLabel}
>
{formTitle}
</span>
</button>
);
}
);

MobileButton.propTypes = {
ariaMobileIconLabel: PropTypes.string.isRequired,
Expand Down
24 changes: 20 additions & 4 deletions src/components/ProgressIndicator/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import React, {useEffect, useState} from 'react';
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {useLocation} from 'react-router-dom';

import Caption from 'components/Caption';
Expand All @@ -18,10 +18,12 @@ const ProgressIndicator = ({
}) => {
const {pathname: currentPathname} = useLocation();
const [expanded, setExpanded] = useState(false);
const [verticalSpaceUsed, setVerticalSpaceUsed] = useState(null);
const buttonRef = useRef(null);

const modifiers = [];
if (!expanded) {
modifiers.push('mobile-collapsed');
if (expanded) {
modifiers.push('expanded');
}

// collapse the expanded progress indicator if nav occurred, see
Expand All @@ -34,9 +36,23 @@ const ProgressIndicator = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPathname]);

useLayoutEffect(() => {
if (buttonRef.current) {
const boundingBox = buttonRef.current.getBoundingClientRect();
// the offset from top + height of the element (including padding + borders)
setVerticalSpaceUsed(boundingBox.bottom);
}
}, [buttonRef, setVerticalSpaceUsed]);

const customProperties = verticalSpaceUsed
? {
'--_of-progress-indicator-nav-mobile-inset-block-start': `${verticalSpaceUsed}px`,
}
: undefined;
return (
<Card blockClassName="progress-indicator" modifiers={modifiers}>
<Card blockClassName="progress-indicator" modifiers={modifiers} style={customProperties}>
<MobileButton
ref={buttonRef}
ariaMobileIconLabel={ariaMobileIconLabel}
accessibleToggleStepsLabel={accessibleToggleStepsLabel}
formTitle={formTitle}
Expand Down
3 changes: 2 additions & 1 deletion src/scss/components/_app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@

// Responsive styles - switch to a column layout and re-order elements.
@include mobile-only {
grid-template-columns: 1fr;
// https://stackoverflow.com/a/63609468 just 1fr doesn't work, but minmax does?
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
'progress-indicator'
'lang-switcher'
Expand Down
123 changes: 49 additions & 74 deletions src/scss/components/_progress-indicator.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
}

@include bem.element('nav') {
// box-sizing: border-box;
display: flex;
flex-direction: column;
gap: var(--of-progress-indicator-nav-gap, 20px);
Expand All @@ -45,6 +46,8 @@
/**
* Responsive styles, mobile viewports.
*
* The default state is collapsed, for the expanded state, see the expanded modifier
* styles.
*/
@include mobile-only {
// remove any padding from the container element - instead, we apply mobile padding
Expand All @@ -54,6 +57,10 @@
--of-progress-indicator-padding-inline-end: 0;
--of-progress-indicator-padding-inline-start: 0;

/**
* The mobile-header button is visible on mobile, and while it is a button for
* acessibility, it should not look like one.
*/
@include bem.element('mobile-header') {
// reset base/default user agent styles
all: unset;
Expand All @@ -64,9 +71,8 @@
display: flex;
justify-content: flex-start;
align-items: center;
gap: var(--of-progress-indicator-mobile-header-gap, 0);
gap: var(--of-progress-indicator-mobile-header-gap, 0px);
inline-size: 100%;
max-inline-size: var(--of-progress-indicator-mobile-header-max-inline-size, 100vw);

padding-block-end: var(--of-progress-indicator-mobile-padding-block-end, 15px);
padding-block-start: var(--of-progress-indicator-mobile-padding-block-start, 15px);
Expand All @@ -85,7 +91,7 @@
.fa-icon {
display: block;
flex-shrink: 0;
flex-basis: calc(2 * var(--of-progress-indicator-padding-mobile-inline-start, 15px));
flex-basis: var(--of-progress-indicator-icon-flex-basis, 30px);
text-align: center;
}
}
Expand All @@ -100,80 +106,49 @@
@include bem.element('nav') {
display: none;
}

// Bit of a BEM violation, but the captions are due a refactor to NL DS at some
// point too.
@include nested('caption') {
display: none;
}

/**
* Appearance for the expanded variant.
*/
@include bem.modifier('expanded') {
@include bem.element('nav') {
--of-list-gap: var(--of-progress-indicator-nav-mobile-list-gap, 15px);

box-shadow: var(--of-progress-indicator-mobile-box-shadow);
box-sizing: border-box;

display: block;
// absolute positioning to not push the content below down, since it must be
// an overlay.
// TODO: there are future CSS features that make anchoring elements to other
// elements much developer-friendlier without requiring 'magic numbers'.
position: absolute;
background: var(--of-progress-indicator-background-color, var(--of-color-bg));

padding-block-end: var(--of-progress-indicator-nav-mobile-padding-block-end, 15px);
padding-block-start: var(--of-progress-indicator-nav-mobile-padding-block-start, 15px);
padding-inline-end: var(--of-progress-indicator-nav-mobile-padding-inline-end, 15px);
padding-inline-start: var(--of-progress-indicator-nav-mobile-padding-inline-start, 30px);

// inset-block-start: var(--_of-progress-indicator-nav-mobile-inset-block-start);
z-index: 1;
inline-size: 100%;

// use the entire viewport minus the block space (vertical) used by the button and potential
// third party elements above
max-block-size: calc(100dvb - var(--_of-progress-indicator-nav-mobile-inset-block-start));
overflow-y: auto;
}
}
}
}

// .#{prefix('progress-indicator')} {

// @include bem.element('mobile-header') {
// @include body;
// @include body--big;
// @include margin(-$grid-container-margin, $properties: margin-left);

// // style for replacing div with a button for accessibility
// background: none;
// border: none;
// padding: 0;
// cursor: pointer;
// width: calc(100% + $grid-container-margin);

// @include show-on-mobile(flex);

// align-items: center;

// .fa-icon {
// display: block;
// flex-shrink: 0;
// flex-basis: $grid-container-margin * 2;
// text-align: center;
// }
// }

// @include bem.element('form-title') {
// @include ellipsis;
// font-weight: bold;
// }

// // mobile styling for the progress indicator
// @include mobile-only {
// @include margin($grid-container-margin, $properties: (padding-top, padding-bottom));
// box-shadow: var(--of-progress-indicator-mobile-box-shadow);
// // otherwise the bar is too short, and setting a width creates a horizontal scrollbar
// width: unset;

// // style layout
// @at-root .#{prefix('layout__row')} & {
// @include margin(
// calc(-1 * var(--of-progress-indicator-mobile-margin)),
// $properties: (margin-left, margin-right)
// );
// }

// @include nested('caption') {
// @include margin(true, $properties: margin-top);
// @include margin($grid-container-margin, $properties: margin-left);

// @include mobile-only {
// display: none;
// }
// }

// @include nested('list') {
// @include margin($grid-container-margin, $properties: margin-left);
// }

// @include bem.modifier('mobile-collapsed') {
// @include nested('caption') {
// display: none;
// }

// @include nested('list') {
// display: none;
// }
// }
// }
// }

// TODO: parametrize with design tokens
.openforms-progress-indicator-item {
display: flex;
Expand Down

0 comments on commit e0ab43e

Please sign in to comment.