Merge pull request #11334 from backstage/camilal/text-size-addon
[TechDocs] Create Text Size Addon
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
+58
-13
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user