Merge pull request #11334 from backstage/camilal/text-size-addon

[TechDocs] Create Text Size Addon
This commit is contained in:
Eric Peterson
2022-05-10 09:28:58 +02:00
committed by GitHub
15 changed files with 570 additions and 18 deletions
@@ -31,4 +31,7 @@ export type ReportIssueTemplateBuilder = ({
// @public
export const techdocsModuleAddonsContribPlugin: BackstagePlugin<{}, {}>;
// @public
export const TextSize: (props: unknown) => JSX.Element | null;
```
@@ -0,0 +1,124 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TechDocsAddonTester } from '@backstage/plugin-techdocs-addons-test-utils';
import React from 'react';
import { fireEvent, waitFor } from '@testing-library/react';
import { TextSize } from '..';
describe('TextSize', () => {
it('renders without exploding', async () => {
const { getByText } = await TechDocsAddonTester.buildAddonsInTechDocs([
<TextSize />,
])
.withDom(<body>TEST_CONTENT</body>)
.renderWithEffects();
expect(getByText('TEST_CONTENT')).toBeInTheDocument();
});
it('changes content text size using slider', async () => {
const { getByTitle, getByText, getByRole, getByDisplayValue } =
await TechDocsAddonTester.buildAddonsInTechDocs([<TextSize />])
.withDom(<body>TEST_CONTENT</body>)
.renderWithEffects();
fireEvent.click(getByTitle('Settings'));
await waitFor(() => {
expect(getByText('Text size')).toBeInTheDocument();
});
const slider = getByRole('slider');
slider.focus();
fireEvent.keyDown(slider, {
key: 'ArrowRight',
});
await waitFor(() => {
expect(getByDisplayValue('115')).toBeInTheDocument();
});
expect(slider).toHaveTextContent('115%');
let style = getComputedStyle(getByText('TEST_CONTENT'));
expect(style.getPropertyValue('--md-typeset-font-size')).toBe('18.4px');
fireEvent.keyDown(slider, {
key: 'ArrowLeft',
});
await waitFor(() => {
expect(getByDisplayValue('100')).toBeInTheDocument();
});
expect(slider).toHaveTextContent('100%');
style = getComputedStyle(getByText('TEST_CONTENT'));
expect(style.getPropertyValue('--md-typeset-font-size')).toBe('16px');
});
it('changes content text size using buttons', async () => {
const {
getByTitle,
getByText,
getByRole,
getByLabelText,
getByDisplayValue,
} = await TechDocsAddonTester.buildAddonsInTechDocs([<TextSize />])
.withDom(<body>TEST_CONTENT</body>)
.renderWithEffects();
fireEvent.click(getByTitle('Settings'));
await waitFor(() => {
expect(getByText('Text size')).toBeInTheDocument();
});
fireEvent.click(getByLabelText('Increase text size'));
await waitFor(() => {
expect(getByDisplayValue('115')).toBeInTheDocument();
});
const slider = getByRole('slider');
expect(slider).toHaveTextContent('115%');
let style = getComputedStyle(getByText('TEST_CONTENT'));
expect(style.getPropertyValue('--md-typeset-font-size')).toBe('18.4px');
fireEvent.click(getByLabelText('Decrease text size'));
await waitFor(() => {
expect(getByDisplayValue('100')).toBeInTheDocument();
});
expect(slider).toHaveTextContent('100%');
style = getComputedStyle(getByText('TEST_CONTENT'));
expect(style.getPropertyValue('--md-typeset-font-size')).toBe('16px');
});
});
@@ -0,0 +1,243 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
ChangeEvent,
MouseEvent,
useMemo,
useState,
useEffect,
useCallback,
} from 'react';
import {
withStyles,
makeStyles,
useTheme,
Box,
MenuItem,
ListItemText,
Slider,
IconButton,
Typography,
} from '@material-ui/core';
import AddIcon from '@material-ui/icons/Add';
import RemoveIcon from '@material-ui/icons/Remove';
import { BackstageTheme } from '@backstage/theme';
import { useShadowRootElements } from '@backstage/plugin-techdocs-react';
const boxShadow =
'0 3px 1px rgba(0,0,0,0.1),0 4px 8px rgba(0,0,0,0.13),0 0 0 1px rgba(0,0,0,0.02)';
const StyledSlider = withStyles(theme => ({
root: {
height: 2,
padding: '15px 0',
},
thumb: {
height: 18,
width: 18,
backgroundColor: theme.palette.common.white,
boxShadow: boxShadow,
marginTop: -9,
marginLeft: -9,
'&:focus, &:hover, &$active': {
boxShadow:
'0 3px 1px rgba(0,0,0,0.1),0 4px 8px rgba(0,0,0,0.3),0 0 0 1px rgba(0,0,0,0.02)',
// Reset on touch devices, it doesn't add specificity
'@media (hover: none)': {
boxShadow: boxShadow,
},
},
},
active: {},
valueLabel: {
top: '100%',
left: '50%',
transform: 'scale(1) translate(-50%, -5px) !important',
'& *': {
color: theme.palette.common.black,
fontSize: theme.typography.caption.fontSize,
background: 'transparent',
},
},
track: {
height: 2,
},
rail: {
height: 2,
opacity: 0.5,
},
mark: {
height: 10,
width: 1,
marginTop: -4,
},
markActive: {
opacity: 1,
backgroundColor: 'currentColor',
},
}))(Slider);
const settings = {
key: 'techdocs.addons.settings.textsize',
defaultValue: 100,
};
const marks = [
{
value: 90,
},
{
value: 100,
},
{
value: 115,
},
{
value: 130,
},
{
value: 150,
},
];
const useStyles = makeStyles((theme: BackstageTheme) => ({
container: {
color: theme.palette.textSubtle,
display: 'flex',
alignItems: 'center',
margin: 0,
minWidth: 200,
},
menuItem: {
'&:hover': {
background: 'transparent',
},
},
decreaseButton: {
marginRight: theme.spacing(1),
},
increaseButton: {
marginLeft: theme.spacing(1),
},
}));
export const TextSizeAddon = () => {
const classes = useStyles();
const theme = useTheme<BackstageTheme>();
const [body] = useShadowRootElements(['body']);
const [value, setValue] = useState<number>(() => {
const initialValue = localStorage?.getItem(settings.key);
return initialValue ? parseInt(initialValue, 10) : settings.defaultValue;
});
const values = useMemo(() => marks.map(mark => mark.value), []);
const index = useMemo(() => values.indexOf(value), [values, value]);
const min = useMemo(() => values[0], [values]);
const max = useMemo(() => values[values.length - 1], [values]);
const getValueText = useCallback(() => `${value}%`, [value]);
const handleChangeCommitted = useCallback(
(_event: ChangeEvent<{}>, newValue: number | number[]) => {
if (!Array.isArray(newValue)) {
setValue(newValue);
localStorage?.setItem(settings.key, String(newValue));
}
},
[setValue],
);
const handleDecreaseClick = useCallback(
(event: MouseEvent) => {
handleChangeCommitted(event, values[index - 1]);
},
[index, values, handleChangeCommitted],
);
const handleIncreaseClick = useCallback(
(event: MouseEvent) => {
handleChangeCommitted(event, values[index + 1]);
},
[index, values, handleChangeCommitted],
);
useEffect(() => {
if (!body) return;
const htmlFontSize =
(
theme.typography as BackstageTheme['typography'] & {
htmlFontSize?: number;
}
)?.htmlFontSize ?? 16;
body.style.setProperty(
'--md-typeset-font-size',
`${htmlFontSize * (value / 100)}px`,
);
}, [body, value, theme]);
return (
<MenuItem className={classes.menuItem} button disableRipple>
<ListItemText
primary={
<Typography variant="subtitle2" color="textPrimary">
Text size
</Typography>
}
secondary={
<Box className={classes.container}>
<IconButton
className={classes.decreaseButton}
size="small"
edge="start"
disabled={value === min}
onClick={handleDecreaseClick}
aria-label="Decrease text size"
>
<RemoveIcon />
</IconButton>
<StyledSlider
value={value}
aria-labelledby="text-size-slider"
getAriaValueText={getValueText}
valueLabelDisplay="on"
valueLabelFormat={getValueText}
marks={marks}
step={null}
min={min}
max={max}
onChangeCommitted={handleChangeCommitted}
/>
<IconButton
className={classes.increaseButton}
size="small"
edge="end"
disabled={value === max}
onClick={handleIncreaseClick}
aria-label="Increase text size"
>
<AddIcon />
</IconButton>
</Box>
}
disableTypography
/>
</MenuItem>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './TextSize';
@@ -20,7 +20,11 @@
* @packageDocumentation
*/
export { techdocsModuleAddonsContribPlugin, ReportIssue } from './plugin';
export {
techdocsModuleAddonsContribPlugin,
ReportIssue,
TextSize,
} from './plugin';
export type {
ReportIssueProps,
ReportIssueTemplate,
@@ -20,6 +20,7 @@ import {
TechDocsAddonLocations,
} from '@backstage/plugin-techdocs-react';
import { ReportIssueAddon, ReportIssueProps } from './ReportIssue';
import { TextSizeAddon } from './TextSize';
/**
* The TechDocs addons contrib plugin
@@ -44,3 +45,49 @@ export const ReportIssue = techdocsModuleAddonsContribPlugin.provide(
component: ReportIssueAddon,
}),
);
/**
* This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons.
*
* @remarks
* The default value for the font size is 100% of the HTML font size, if the theme does not have a `htmlFontSize` in its typography object, the addon will assume 16px as 100%, and remember, this setting is kept in the browser local storage.
*
* @example
* Here's a simple example:
* ```
* import {
* DefaultTechDocsHome,
* TechDocsIndexPage,
* TechDocsReaderPage,
* } from '@backstage/plugin-techdocs';
* import { TechDocsAddons } from '@backstage/plugin-techdocs-react/alpha';
* import { TextSize } from '@backstage/plugin-techdocs-module-addons-contrib';
*
*
* const AppRoutes = () => {
* <FlatRoutes>
* // other plugin routes
* <Route path="/docs" element={<TechDocsIndexPage />}>
* <DefaultTechDocsHome />
* </Route>
* <Route
* path="/docs/:namespace/:kind/:name/*"
* element={<TechDocsReaderPage />}
* >
* <TechDocsAddons>
* <TextSize />
* </TechDocsAddons>
* </Route>
* </FlatRoutes>;
* };
* ```
*
* @public
*/
export const TextSize = techdocsModuleAddonsContribPlugin.provide(
createTechDocsAddonExtension({
name: 'TextSize',
location: TechDocsAddonLocations.Settings,
component: TextSizeAddon,
}),
);
+1
View File
@@ -32,6 +32,7 @@ export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1';
export const TechDocsAddonLocations: Readonly<{
readonly Header: 'Header';
readonly Subheader: 'Subheader';
readonly Settings: 'Settings';
readonly PrimarySidebar: 'PrimarySidebar';
readonly SecondarySidebar: 'SecondarySidebar';
readonly Content: 'Content';
+6
View File
@@ -53,6 +53,12 @@ export const TechDocsAddonLocations = Object.freeze({
*/
Subheader: 'Subheader',
/**
* These addons are items added to the settings menu list and are designed to make
* the reader experience customizable, for example accessibility options
*/
Settings: 'Settings',
/**
* These addons appear left of the content and above the navigation.
*/
@@ -14,9 +14,18 @@
* limitations under the License.
*/
import React from 'react';
import React, { MouseEvent, useState, useCallback } from 'react';
import { Box, makeStyles, Toolbar, ToolbarProps } from '@material-ui/core';
import {
Box,
makeStyles,
Toolbar,
ToolbarProps,
Menu,
Tooltip,
IconButton,
} from '@material-ui/core';
import SettingsIcon from '@material-ui/icons/Settings';
import {
TechDocsAddonLocations as locations,
@@ -44,31 +53,67 @@ export const TechDocsReaderPageSubheader = ({
toolbarProps?: ToolbarProps;
}) => {
const classes = useStyles();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const {
entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
} = useTechDocsReaderPage();
const addons = useTechDocsAddons();
const subheaderAddons = addons.renderComponentsByLocation(
locations.Subheader,
);
if (!subheaderAddons) return null;
const settingsAddons = addons.renderComponentsByLocation(locations.Settings);
if (!subheaderAddons && !settingsAddons) return null;
// No entity metadata = 404. Don't render subheader on 404.
if (entityMetadataLoading === false && !entityMetadata) return null;
return (
<Toolbar classes={classes} {...toolbarProps}>
{subheaderAddons && (
<Box
display="flex"
justifyContent="flex-end"
width="100%"
flexWrap="wrap"
>
{subheaderAddons}
</Box>
)}
<Box
display="flex"
justifyContent="flex-end"
width="100%"
flexWrap="wrap"
>
{subheaderAddons}
{settingsAddons ? (
<>
<Tooltip title="Settings">
<IconButton
aria-controls="tech-docs-reader-page-settings"
aria-haspopup="true"
onClick={handleClick}
>
<SettingsIcon />
</IconButton>
</Tooltip>
<Menu
id="tech-docs-reader-page-settings"
getContentAnchorEl={null}
anchorEl={anchorEl}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
open={Boolean(anchorEl)}
onClose={handleClose}
keepMounted
>
{settingsAddons}
</Menu>
</>
) : null}
</Box>
</Toolbar>
);
};