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

Add a FieldCheckbox component #43171

Merged
merged 2 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
39 changes: 20 additions & 19 deletions web/packages/design/src/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import styled from 'styled-components';

import React from 'react';
import React, { forwardRef } from 'react';

import { Flex } from 'design';
import { space } from 'design/system';
Expand Down Expand Up @@ -52,7 +52,7 @@ export const CheckboxInput = styled.input`
${space}
`;

type CheckboxSize = 'large' | 'small';
export type CheckboxSize = 'large' | 'small';

interface StyledCheckboxProps {
size?: CheckboxSize;
Expand All @@ -79,27 +79,28 @@ interface StyledCheckboxProps {
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

// TODO (bl-nero): Make this the default checkbox
export function StyledCheckbox(props: StyledCheckboxProps) {
const { style, className, size, ...inputProps } = props;
return (
// The outer wrapper and inner wrapper are separate to allow using
// positioning CSS attributes on the checkbox while still maintaining its
// internal integrity that requires the internal wrapper to be positioned.
<OuterWrapper style={style} className={className}>
<InnerWrapper>
{/* The checkbox is rendered as two items placed on top of each other:
export const StyledCheckbox = forwardRef<HTMLInputElement, StyledCheckboxProps>(
(props, ref) => {
const { style, className, size, ...inputProps } = props;
return (
// The outer wrapper and inner wrapper are separate to allow using
// positioning CSS attributes on the checkbox while still maintaining its
// internal integrity that requires the internal wrapper to be positioned.
<OuterWrapper style={style} className={className}>
<InnerWrapper>
{/* The checkbox is rendered as two items placed on top of each other:
the actual checkbox, which is a native input control, and an SVG
checkmark. Note that we avoid the usual "label with content" trick,
because we want to be able to use this component both with and
without surrounding labels. Instead, we use absolute positioning and
an actually rendered input with a custom appearance. */}
<StyledCheckboxInternal cbSize={size} {...inputProps} />
<Checkmark />
</InnerWrapper>
</OuterWrapper>
);
}
<CheckboxInternal ref={ref} cbSize={size} {...inputProps} />
<Checkmark />
</InnerWrapper>
</OuterWrapper>
);
}
);

const OuterWrapper = styled.span`
line-height: 0;
Expand Down Expand Up @@ -132,7 +133,7 @@ const Checkmark = styled(Icon.CheckThick)`
}
`;

export const StyledCheckboxInternal = styled.input.attrs(props => ({
const CheckboxInternal = styled.input.attrs(props => ({
// TODO(bl-nero): Make radio buttons a separate control.
type: props.type || 'checkbox',
}))`
Expand Down
7 changes: 6 additions & 1 deletion web/packages/design/src/Checkbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

export { CheckboxInput, CheckboxWrapper, StyledCheckbox } from './Checkbox';
export {
CheckboxInput,
CheckboxWrapper,
StyledCheckbox,
type CheckboxSize,
} from './Checkbox';
20 changes: 20 additions & 0 deletions web/packages/design/src/theme/typography.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ const typography = {
fontSize: '14px',
lineHeight: '20px',
},

// TODO(bl-nero): Migrate everything to the new typography.
newBody1: {
fontSize: '16px',
fontWeight: light,
lineHeight: '24px',
letterSpacing: '0.08px',
},
newBody2: {
fontSize: '14px',
fontWeight: light,
lineHeight: '20px',
letterSpacing: '0.035px',
},
newBody3: {
fontSize: '12px',
fontWeight: regular,
lineHeight: '20px',
letterSpacing: '0.015px',
},
};

export default typography;
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,15 @@ import {
Cross,
} from 'design/Icon';
import Table, { Cell } from 'design/DataTable';
import { CheckboxInput, CheckboxWrapper } from 'design/Checkbox';
import { Danger } from 'design/Alert';

import Validation, { useRule, Validator } from 'shared/components/Validation';
import { Attempt } from 'shared/hooks/useAttemptNext';
import { pluralize } from 'shared/utils/text';
import { Option } from 'shared/components/Select';

import { FieldCheckbox } from 'shared/components/FieldCheckbox';

import { CreateRequest } from '../../Shared/types';
import { AssumeStartTime } from '../../AssumeStartTime/AssumeStartTime';
import { AccessDurationRequest } from '../../AccessDuration';
Expand Down Expand Up @@ -521,34 +522,20 @@ function ResourceRequestRoles({
{fetchAttempt.status === 'success' && expanded && (
<Box mt={2}>
{roles.map((roleName, index) => {
const id = `${roleName}${index}`;
const checked = selectedRoles.includes(roleName);
return (
<CheckboxWrapper
key={index}
css={`
width: 100%;
cursor: pointer;
background: ${({ theme }) => theme.colors.levels.surface};

&:hover {
border-color: ${({ theme }) =>
theme.colors.levels.elevated};
}
`}
as="label"
htmlFor={id}
>
<CheckboxInput
type="checkbox"
<RoleRowContainer checked={checked}>
<StyledFieldCheckbox
key={index}
name={roleName}
id={id}
onChange={e => {
onInputChange(roleName, e);
}}
checked={selectedRoles.includes(roleName)}
checked={checked}
label={roleName}
size="small"
/>
{roleName}
</CheckboxWrapper>
</RoleRowContainer>
);
})}
{selectedRoles.length < roles.length && (
Expand Down Expand Up @@ -577,6 +564,45 @@ function ResourceRequestRoles({
);
}

const RoleRowContainer = styled.div<{ checked?: boolean }>`
transition: all 150ms;
position: relative;

// TODO(bl-nero): That's the third place where we're copying these
// definitions. We need to make them reusable.
:hover {
background-color: ${props => props.theme.colors.levels.surface};

// We use a pseudo element for the shadow with position: absolute in order to prevent
// the shadow from increasing the size of the layout and causing scrollbar flicker.
:after {
box-shadow: ${props => props.theme.boxShadow[3]};
content: '';
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
}
}
`;

const StyledFieldCheckbox = styled(FieldCheckbox)`
margin: 0;
padding: ${p => p.theme.space[2]}px;
background-color: ${props =>
props.checked
? props.theme.colors.interactive.tonal.primary[2]
: 'transparent'};
border-bottom: ${props => props.theme.borders[2]}
${props => props.theme.colors.interactive.tonal.neutral[0]};

& > label {
display: block; // make it full-width
}
`;

function TextBox({
reason,
updateReason,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from 'react';

import Box from 'design/Box';

import { FieldCheckbox } from '.';

export default {
title: 'Shared',
};

export const FieldCheckboxStory = () => (
<Box width={600}>
<FieldCheckbox label="Unchecked checkbox" defaultChecked={false} />
<FieldCheckbox label="Checked checkbox" defaultChecked={true} />
<FieldCheckbox label="Disabled checkbox" disabled />
<FieldCheckbox size="small" label="Small checkbox" defaultChecked={true} />
<FieldCheckbox
label="Checkbox with helper text"
helperText="I'm a helpful helper text"
defaultChecked={true}
/>
<FieldCheckbox
size="small"
label="Small checkbox with helper text"
helperText="Another helpful helper text"
/>
<FieldCheckbox
disabled
label="Disabled checkbox with helper text"
helperText="There's nothing you can do here"
defaultChecked={true}
/>
<FieldCheckbox
label="To check, or not to check: that is the question:
Whether 'tis nobler in the mind to suffer
The slings and arrows of outrageous fortune,
Or to take arms against a sea of troubles,
And by opposing end them?"
helperText="To uncheck: to sleep;
No more; and by a sleep to say we end
The heart-ache and the thousand natural shocks
That flesh is heir to, 'tis a consummation
Devoutly to be wish'd."
/>
</Box>
);

FieldCheckboxStory.storyName = 'FieldCheckbox';
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, screen, userEvent } from 'design/utils/testing';
import React, { useRef, useState } from 'react';

import { FieldCheckbox } from './FieldCheckbox';

test('controlled flow', async () => {
const onChange = jest.fn();

function TestField() {
const [checked, setChecked] = useState(false);
function onCbChange(e: React.ChangeEvent<HTMLInputElement>) {
const c = e.currentTarget.checked;
setChecked(c);
onChange(c);
}
return (
<FieldCheckbox label="I agree" checked={checked} onChange={onCbChange} />
);
}

const user = userEvent.setup();
render(<TestField />);

await user.click(screen.getByLabelText('I agree'));
expect(screen.getByLabelText('I agree')).toBeChecked();
expect(onChange).toHaveBeenLastCalledWith(true);

await user.click(screen.getByLabelText('I agree'));
expect(screen.getByLabelText('I agree')).not.toBeChecked();
expect(onChange).toHaveBeenLastCalledWith(false);
});

test('uncontrolled flow', async () => {
let checkboxRef;
function TestForm() {
const cbRefInternal = useRef();
checkboxRef = cbRefInternal;
return (
<form data-testid="form">
<FieldCheckbox ref={cbRefInternal} name="ack" label="Make it so" />
</form>
);
}

const user = userEvent.setup();
render(<TestForm />);
expect(screen.getByTestId('form')).toHaveFormValues({});

await user.click(screen.getByLabelText('Make it so'));
expect(screen.getByTestId('form')).toHaveFormValues({ ack: true });
expect(checkboxRef.current.checked).toBe(true);

await user.click(screen.getByLabelText('Make it so'));
expect(screen.getByTestId('form')).toHaveFormValues({});
expect(checkboxRef.current.checked).toBe(false);
});
Loading
Loading