Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(theming): add focus styling utilities #1542

Merged
merged 19 commits into from
May 3, 2023
Merged
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
22 changes: 11 additions & 11 deletions packages/theming/.size-snapshot.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
{
"index.cjs.js": {
"bundled": 21035,
"minified": 13433,
"gzipped": 5155
},
"index.esm.js": {
"bundled": 18296,
"minified": 11411,
"gzipped": 4620,
"bundled": 20042,
"minified": 12530,
"gzipped": 5047,
"treeshaked": {
"rollup": {
"code": 1819,
"import_statements": 146
"code": 3933,
"import_statements": 216
},
"webpack": {
"code": 2388
"code": 4201
}
}
},
"index.cjs.js": {
"bundled": 19098,
"minified": 12154,
"gzipped": 4707
}
}
4 changes: 4 additions & 0 deletions packages/theming/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"dependencies": {
"@zendeskgarden/container-focusvisible": "^1.0.0",
"@zendeskgarden/container-utilities": "^1.0.0",
"lodash.memoize": "^4.1.2",
"polished": "^4.0.0",
"prop-types": "^15.5.7"
},
Expand All @@ -31,6 +32,9 @@
"react-dom": ">=16.8.0",
"styled-components": "^4.2.0 || ^5.0.0"
},
"devDependencies": {
"@types/lodash.memoize": "4.1.7"
},
"keywords": [
"components",
"garden",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,13 @@ exports[`DEFAULT_THEME matches snapshot 1`] = `
"shadowWidths": {
"md": "3px",
"sm": "2px",
"xs": "1px",
},
"shadows": {
"lg": "0 0 0 0 black",
"md": "0 0 0 3px black",
"sm": "0 0 0 2px black",
"xs": "0 0 0 1px black",
},
"space": {
"base": 4,
Expand Down
1 change: 1 addition & 0 deletions packages/theming/src/elements/theme/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('DEFAULT_THEME', () => {
expect({
...DEFAULT_THEME,
shadows: {
xs: DEFAULT_THEME.shadows.xs('black'),
sm: DEFAULT_THEME.shadows.sm('black'),
md: DEFAULT_THEME.shadows.md('black'),
lg: DEFAULT_THEME.shadows.lg('0', '0', 'black')
Expand Down
2 changes: 2 additions & 0 deletions packages/theming/src/elements/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,13 @@ const palette = { ...PALETTE };
delete (palette as any).product;

const shadowWidths = {
xs: '1px',
sm: '2px',
md: '3px'
};

const shadows = {
xs: (color: string) => `0 0 0 ${shadowWidths.xs} ${color}`,
sm: (color: string) => `0 0 0 ${shadowWidths.sm} ${color}`,
md: (color: string) => `0 0 0 ${shadowWidths.md} ${color}`,
lg: (offsetY: string, blurRadius: string, color: string) =>
Expand Down
4 changes: 3 additions & 1 deletion packages/theming/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ export {
} from './utils/retrieveComponentStyles';
export { default as withTheme } from './utils/withTheme';
export { default as getDocument } from './utils/getDocument';
export { default as getColor } from './utils/getColor';
export { getColor } from './utils/getColor';
export { getFocusBoxShadow } from './utils/getFocusBoxShadow';
export { default as getLineHeight } from './utils/getLineHeight';
export { default as mediaQuery } from './utils/mediaQuery';
export { default as arrowStyles } from './utils/arrowStyles';
export { useDocument } from './utils/useDocument';
export { useWindow } from './utils/useWindow';
export { useText } from './utils/useText';
export { default as menuStyles } from './utils/menuStyles';
export { focusStyles, SELECTOR_FOCUS_VISIBLE } from './utils/focusStyles';

export {
ARROW_POSITION as ARRAY_ARROW_POSITION,
Expand Down
2 changes: 2 additions & 0 deletions packages/theming/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,12 @@ export interface IGardenTheme {
xxxl: string;
};
shadowWidths: {
xs: string;
sm: string;
md: string;
};
shadows: {
xs: (color: string) => string;
sm: (color: string) => string;
md: (color: string) => string;
lg: (offsetY: string, blurRadius: string, color: string) => string;
Expand Down
138 changes: 138 additions & 0 deletions packages/theming/src/utils/focusStyles.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Copyright Zendesk, Inc.
*
* Use of this source code is governed under the Apache License, Version 2.0
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/

import React from 'react';
import { render } from 'garden-test-utils';
import styled, { ThemeProps, DefaultTheme, CSSObject } from 'styled-components';
import { focusStyles } from './focusStyles';
import { Hue } from './getColor';
import DEFAULT_THEME from '../elements/theme';
import PALETTE from '../elements/palette';

interface IStyledDivProps extends ThemeProps<DefaultTheme> {
condition?: boolean;
inset?: boolean;
hue?: Hue;
selector?: string;
shade?: number;
shadowWidth?: 'sm' | 'md';
spacerWidth?: null | 'xs' | 'sm';
styles?: CSSObject;
}

const StyledDiv = styled.div<IStyledDivProps>`
${props => focusStyles(props)}
`;

describe('focusStyles', () => {
it('renders with expected defaults', () => {
const { container } = render(<StyledDiv />);
const expected = `${DEFAULT_THEME.shadowWidths.md} ${PALETTE.blue[600]}`;

expect(container.firstChild).toHaveStyleRule('outline', 'none', { modifier: '&:focus' });
expect(container.firstChild).toHaveStyleRule('box-shadow', expect.stringContaining(expected), {
modifier: '&:focus-visible'
});
expect(container.firstChild).toHaveStyleRule('box-shadow', expect.stringContaining(expected), {
modifier: "&[data-garden-focus-visible='true']"
});
expect(container.firstChild).toHaveStyleRule(
'outline',
expect.stringContaining('2px solid transparent'),
{
modifier: '&:focus-visible'
}
);
expect(container.firstChild).toHaveStyleRule(
'outline-offset',
expect.stringContaining(`${DEFAULT_THEME.shadowWidths.xs}`),
{
modifier: '&:focus-visible'
}
);
});

it('renders inset as expected', () => {
const { container } = render(<StyledDiv inset />);

expect(container.firstChild).toHaveStyleRule('box-shadow', expect.stringContaining('inset'), {
modifier: '&:focus-visible'
});
});

it('renders color as expected', () => {
const { container } = render(<StyledDiv hue="red" shade={400} />);

expect(container.firstChild).toHaveStyleRule(
'box-shadow',
expect.stringContaining(`${DEFAULT_THEME.shadowWidths.md} ${PALETTE.red[400]}`),
{
modifier: '&:focus-visible'
}
);
});

it('renders selector as expected', () => {
const { container } = render(<StyledDiv selector="&:focus-within" />);

expect(container.firstChild).toHaveStyleRule(
'box-shadow',
expect.stringContaining(`${PALETTE.blue[600]}`),
{
modifier: '&:focus-within'
}
);
});

it('renders size as expected', () => {
const { container } = render(<StyledDiv shadowWidth="sm" spacerWidth="sm" />);

expect(container.firstChild).toHaveStyleRule(
'box-shadow',
expect.stringContaining(`${DEFAULT_THEME.shadowWidths.sm} ${PALETTE.blue[600]}`),
{
modifier: '&:focus-visible'
}
);
expect(container.firstChild).toHaveStyleRule(
'box-shadow',
expect.stringContaining(`${DEFAULT_THEME.shadowWidths.sm} ${PALETTE.white}`),
{
modifier: '&:focus-visible'
}
);
});

it('conditionally renders without `box-shadow`', () => {
const { container } = render(<StyledDiv condition={false} />);

expect(container.firstChild).not.toHaveStyleRule('box-shadow', undefined, {
modifier: '&:focus-visible'
});
});

it('knocks out spacer as expected', () => {
const { container } = render(<StyledDiv spacerWidth={null} />);

expect(container.firstChild).not.toHaveStyleRule('outline-offset', undefined, {
modifier: '&:focus-visible'
});
});

it('renders user provided styles', () => {
const { container } = render(
<StyledDiv styles={{ backgroundColor: 'black', color: 'white' }} />
);

expect(container.firstChild).toHaveStyleRule('background-color', 'black', {
modifier: '&:focus-visible'
});
expect(container.firstChild).toHaveStyleRule('color', 'white', {
modifier: '&:focus-visible'
});
});
});
89 changes: 89 additions & 0 deletions packages/theming/src/utils/focusStyles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Copyright Zendesk, Inc.
*
* Use of this source code is governed under the Apache License, Version 2.0
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/

import { css, CSSObject } from 'styled-components';
import { math } from 'polished';
import { FocusBoxShadowParameters, getFocusBoxShadow } from './getFocusBoxShadow';

export const SELECTOR_FOCUS_VISIBLE = '&:focus-visible, &[data-garden-focus-visible="true"]';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:focus-visible πŸš€ πŸš€


type FocusStylesParameters = FocusBoxShadowParameters & {
condition?: boolean;
selector?: string;
styles?: CSSObject;
};

/**
* Garden standard `box-shadow` focus styling.
*
* @param {boolean} [options.condition=true] Supplies an optional condition that can be used to prevent the focus `box-shadow`
* @param {boolean} [options.inset=false] Determines whether the `box-shadow` is inset
* @param {string|Object} [options.hue='primaryHue'] Provides a theme object `palette` hue or `color` key, or any valid CSS color notation
* @param {string} [options.selector=SELECTOR_FOCUS_VISIBLE] Provides a subsitute `:focus-visible` pseudo-class CSS selector.
* @param {number} [options.shade=600] Selects a shade for the given hue
* @param {string} [options.shadowWidth='md'] Provides a theme object `shadowWidth` key for the cumulative width of the `box-shadow`
* @param {string} [options.spacerWidth='xs'] Provides a theme object `shadowWidth` for the white spacer, or `null` to remove
* @param {Object} [options.styles] Adds CSS property values to be rendered with `:focus-visible`
* @param {Object} options.theme Provides values used to resolve the desired color
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @param {Object} options.theme Provides values used to resolve the desired color
* @param {Object} [options.theme] Provides values used to resolve the desired color

Assuming the brackets were left out on accident. :)

*
* @returns CSS structured as follows, with `{values}` determined by the options provided:
* ```css
* :focus {
* outline: none;
* }
*
* :focus-visible,
* [data-garden-focus-visible='true'] {
* box-shadow: 0 0 0 {1px} #fff,
* 0 0 0 {3px} {blue};
* outline: {2px} solid transparent;
* outline-offset: {1px};
* // additional {styles} here...
* }
* ```
*/
export const focusStyles = ({
condition = true,
Copy link
Contributor

@Francois-Esquire Francois-Esquire May 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious, what advantage does the condition argument bring? The condition argument can be done in the context of the code where focusStyles is used; which reads clearer and less surface area to debug.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See commit linked in description

selector = SELECTOR_FOCUS_VISIBLE,
shadowWidth = 'md',
spacerWidth = 'xs',
styles,
theme,
...options
}: FocusStylesParameters) => {
const boxShadow = condition
? getFocusBoxShadow({ shadowWidth, spacerWidth, theme, ...options })
: undefined;
let outline;
let outlineOffset;

if (spacerWidth === null) {
outline = theme.shadowWidths[shadowWidth];
} else {
outline = `${math(
`${theme.shadowWidths[shadowWidth]} - ${theme.shadowWidths[spacerWidth]}`
)} solid transparent`;
outlineOffset = theme.shadowWidths[spacerWidth];
}

/*
* 1. Browser reset
* 2. High contrast mode hack
Copy link
Contributor

@Francois-Esquire Francois-Esquire May 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Can you please share your resource/research on the high contrast mode hack? Very curious to learn more about this approach.
  • Will there need to be other parts to this mechanism? An extension to the theme colors with a "high contrast color" token(s)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See link in description

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for transparent.

*/
return css`
&:focus {
outline: none; /* [1] */
}

${selector} {
outline: ${outline}; /* [2] */
outline-offset: ${outlineOffset};
box-shadow: ${boxShadow};
${styles}
}
`;
};
2 changes: 1 addition & 1 deletion packages/theming/src/utils/getColor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/

import getColor from './getColor';
import { getColor } from './getColor';
import PALETTE from '../elements/palette';
import DEFAULT_THEME from '../elements/theme';
import { darken, lighten, rgba } from 'polished';
Expand Down
Loading