Skip to content

Commit

Permalink
Add reusable preferences modal to interface package. (#39153)
Browse files Browse the repository at this point in the history
* Add reusable preferences modal to interface package.

* Rearrange and export files

* Make sure sections exist.

* Add readmes for all components.

* Update readme code example.

Co-authored-by: Daniel Richards <daniel.richards@automattic.com>

* Split out tabs from modal.

* Mark base option component unstable.

* Add classname and move some styles around.

Co-authored-by: Daniel Richards <daniel.richards@automattic.com>
  • Loading branch information
tellthemachines and talldan authored Mar 8, 2022
1 parent 98ba5c2 commit 52ec524
Show file tree
Hide file tree
Showing 14 changed files with 475 additions and 0 deletions.
4 changes: 4 additions & 0 deletions packages/interface/src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ export { default as PinnedItems } from './pinned-items';
export { default as MoreMenuDropdown } from './more-menu-dropdown';
export { default as MoreMenuFeatureToggle } from './more-menu-feature-toggle';
export { default as ActionItem } from './action-item';
export { default as PreferencesModal } from './preferences-modal';
export { default as PreferencesModalTabs } from './preferences-modal-tabs';
export { default as PreferencesModalSection } from './preferences-modal-section';
export { default as ___unstablePreferencesModalBaseOption } from './preferences-modal-base-option';
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#__unstablePreferencesModalBaseOption

`__unstablePreferencesModalBaseOption` renders a toggle meant to be used with `PreferencesModal`.

This component implements a `ToggleControl` component from the `@wordpress/components` package.

**It is an unstable component so is subject to breaking changes at any moment. Use at own risk.**

## Example

```jsx
function MyEditorPreferencesOption() {
return (
<__unstablePreferencesModalBaseOption
label={ label }
isChecked={ isChecked }
onChange={ setIsChecked }
>
{ isChecked !== areCustomFieldsEnabled && (
<CustomFieldsConfirmation willEnable={ isChecked } />
) }
</__unstablePreferencesModalBaseOption>
)
}
```

## Props

### help
### label
### isChecked
### onChange

These props are passed directly to ToggleControl, so see [ToggleControl readme](https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/toggle-control/README.md) for more info.

### children

Components to be rendered as content.

- Type: `Element`
- Required: No.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* WordPress dependencies
*/
import { ToggleControl } from '@wordpress/components';

function BaseOption( { help, label, isChecked, onChange, children } ) {
return (
<div className="interface-preferences-modal__option">
<ToggleControl
help={ help }
label={ label }
checked={ isChecked }
onChange={ onChange }
/>
{ children }
</div>
);
}

export default BaseOption;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.interface-preferences-modal__option {
.components-base-control {
.components-base-control__field {
align-items: center;
display: flex;
margin-bottom: 0;

& > label {
flex-grow: 1;
padding: 0.6rem 0 0.6rem 10px;
}
}
}

.components-base-control__help {
margin: -$grid-unit-10 0 $grid-unit-10 58px;
font-size: $helptext-font-size;
font-style: normal;
color: $gray-700;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@

`PreferencesModalSection` renders a section (as a fieldset) meant to be used with `PreferencesModal`.

## Example

See the `PreferencesModal` readme for usage info.


## Props

### title

The title of the section

- Type: `String`
- Required: Yes.

### description

The description for the section.

- Type: `String`
- Required: No.

### children

Components to be rendered as content.

- Type: `Element`
- Required: Yes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const Section = ( { description, title, children } ) => (
<fieldset className="interface-preferences-modal__section">
<legend>
<h2 className="interface-preferences-modal__section-title">
{ title }
</h2>
{ description && (
<p className="interface-preferences-modal__section-description">
{ description }
</p>
) }
</legend>
{ children }
</fieldset>
);

export default Section;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.interface-preferences-modal__section {
margin: 0 0 2.5rem 0;

&:last-child {
margin: 0;
}
}

.interface-preferences-modal__section-title {
font-size: 0.9rem;
font-weight: 600;
margin-top: 0;
}

.interface-preferences-modal__section-description {
margin: -$grid-unit-10 0 $grid-unit-10 0;
font-size: $helptext-font-size;
font-style: normal;
color: $gray-700;
}
14 changes: 14 additions & 0 deletions packages/interface/src/components/preferences-modal-tabs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# PreferencesModalTabs

`PreferencesModalTabs` creates a tabbed interface meant to be used inside a `PreferencesModal`. Markup differs between small and large viewports; on small the tabs are closed by default.

## Example

See the `PreferencesModal` readme for usage info.
## Props
### sections

Sections to populate the modal with. Takes an array of objects, where each should include `name`, `tablabel` and `content`.

- Type: `Array`
- Required: Yes.
156 changes: 156 additions & 0 deletions packages/interface/src/components/preferences-modal-tabs/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* WordPress dependencies
*/
import { useViewportMatch } from '@wordpress/compose';
import {
__experimentalNavigatorProvider as NavigatorProvider,
__experimentalNavigatorScreen as NavigatorScreen,
__experimentalNavigatorButton as NavigatorButton,
__experimentalNavigatorBackButton as NavigatorBackButton,
__experimentalItemGroup as ItemGroup,
__experimentalItem as Item,
__experimentalHStack as HStack,
__experimentalText as Text,
__experimentalTruncate as Truncate,
FlexItem,
TabPanel,
Card,
CardHeader,
CardBody,
} from '@wordpress/components';
import { useMemo, useCallback, useState } from '@wordpress/element';
import { chevronLeft, chevronRight, Icon } from '@wordpress/icons';
import { isRTL, __ } from '@wordpress/i18n';

const PREFERENCES_MENU = 'preferences-menu';

export default function PreferencesModalTabs( { sections } ) {
const isLargeViewport = useViewportMatch( 'medium' );

// This is also used to sync the two different rendered components
// between small and large viewports.
const [ activeMenu, setActiveMenu ] = useState( PREFERENCES_MENU );
/**
* Create helper objects from `sections` for easier data handling.
* `tabs` is used for creating the `TabPanel` and `sectionsContentMap`
* is used for easier access to active tab's content.
*/
const { tabs, sectionsContentMap } = useMemo( () => {
let mappedTabs = {
tabs: [],
sectionsContentMap: {},
};
if ( sections.length ) {
mappedTabs = sections.reduce(
( accumulator, { name, tabLabel: title, content } ) => {
accumulator.tabs.push( { name, title } );
accumulator.sectionsContentMap[ name ] = content;
return accumulator;
},
{ tabs: [], sectionsContentMap: {} }
);
}
return mappedTabs;
}, [ sections ] );

const getCurrentTab = useCallback(
( tab ) => sectionsContentMap[ tab.name ] || null,
[ sectionsContentMap ]
);

let modalContent;
// We render different components based on the viewport size.
if ( isLargeViewport ) {
modalContent = (
<TabPanel
className="interface-preferences__tabs"
tabs={ tabs }
initialTabName={
activeMenu !== PREFERENCES_MENU ? activeMenu : undefined
}
onSelect={ setActiveMenu }
orientation="vertical"
>
{ getCurrentTab }
</TabPanel>
);
} else {
modalContent = (
<NavigatorProvider
initialPath="/"
className="interface-preferences__provider"
>
<NavigatorScreen path="/">
<Card isBorderless size="small">
<CardBody>
<ItemGroup>
{ tabs.map( ( tab ) => {
return (
<NavigatorButton
key={ tab.name }
path={ tab.name }
as={ Item }
isAction
>
<HStack justify="space-between">
<FlexItem>
<Truncate>
{ tab.title }
</Truncate>
</FlexItem>
<FlexItem>
<Icon
icon={
isRTL()
? chevronLeft
: chevronRight
}
/>
</FlexItem>
</HStack>
</NavigatorButton>
);
} ) }
</ItemGroup>
</CardBody>
</Card>
</NavigatorScreen>
{ sections.length &&
sections.map( ( section ) => {
return (
<NavigatorScreen
key={ `${ section.name }-menu` }
path={ section.name }
>
<Card isBorderless size="large">
<CardHeader
isBorderless={ false }
justify="left"
size="small"
gap="6"
>
<NavigatorBackButton
icon={
isRTL()
? chevronRight
: chevronLeft
}
aria-label={ __(
'Navigate to the previous view'
) }
/>
<Text size="16">
{ section.tabLabel }
</Text>
</CardHeader>
<CardBody>{ section.content }</CardBody>
</Card>
</NavigatorScreen>
);
} ) }
</NavigatorProvider>
);
}

return modalContent;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
$vertical-tabs-width: 160px;

.interface-preferences__tabs {
.components-tab-panel__tabs {
position: absolute;
top: $header-height + $grid-unit-30;
// Aligns button text instead of button box.
left: $grid-unit-20;
width: $vertical-tabs-width;
.components-tab-panel__tabs-item {
border-radius: $radius-block-ui;
font-weight: 400;
&.is-active {
background: $gray-100;
box-shadow: none;
font-weight: 500;
}
&:focus:not(:disabled) {
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
}
}
}
.components-tab-panel__tab-content {
padding-left: $grid-unit-30;
margin-left: $vertical-tabs-width;
}
}

@media (max-width: #{ ($break-medium - 1) }) {
// Keep the navigator component from overflowing the modal content area
// to ensure that sticky position elements stick where intended.
.interface-preferences__provider {
height: 100%;
}
}
Loading

0 comments on commit 52ec524

Please sign in to comment.