Skip to content
Closed
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
75 changes: 75 additions & 0 deletions src/components/input/RichCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import classnames from 'classnames';
import type { ComponentChildren } from 'preact';
import { useCallback } from 'preact/hooks';

import { CheckboxCheckedFilledIcon, CheckboxIcon } from '../icons';

export type RichCheckboxProps = {
checked: boolean;
onChange: (checked: boolean) => void;

/**
* Content provided as children is vertically aligned with the checkbox icon
*/
children: ComponentChildren;

/**
* Allows to provide extra content to be displayed under the children, in a
* smaller and more subtle font color.
*/
subtitle?: ComponentChildren;
};

/**
* An opinionated `[role="checkbox"]` component which displays a checkbox icon
* next to provided content.
*
* If a `subtitle` is provided, it will be shown in a lighter color right under
* the main content, and aligned on the left with it.
*/
export default function RichCheckbox({
checked,
onChange,
children,
subtitle,
}: RichCheckboxProps) {
const toggle = useCallback(() => onChange(!checked), [checked, onChange]);

return (
<div
className={classnames(
'group focus-visible-ring',
'grid gap-x-1.5 items-center grid-cols-[auto_1fr]',
'px-3 py-2 rounded-lg cursor-pointer',
'hover:bg-grey-3/25 aria-checked:bg-grey-3/50',
)}
role="checkbox"
aria-checked={checked}
onClick={toggle}
onKeyDown={e => {
if (['Enter', ' '].includes(e.key)) {
e.preventDefault();
toggle();
}
}}
tabIndex={0}
>
{!checked && <CheckboxIcon />}
{checked && <CheckboxCheckedFilledIcon />}
<p className="text-grey-7 group-hover:text-grey-8 group-aria-checked:text-grey-8">
{children}
</p>
{subtitle && (
<>
<div />
<p
data-testid="subtitle"
className="text-grey-6 group-hover:text-grey-7 group-aria-checked:text-grey-7"
>
{subtitle}
</p>
</>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/input/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { default as InputGroup } from './InputGroup';
export { default as OptionButton } from './OptionButton';
export { default as RadioButton } from './RadioButton';
export { default as RadioGroup } from './RadioGroup';
export { default as RichCheckbox } from './RichCheckbox';
export { Select, MultiSelect } from './Select';
export { default as Textarea } from './Textarea';

Expand All @@ -19,5 +20,6 @@ export type { InputGroupProps } from './InputGroup';
export type { OptionButtonProps } from './OptionButton';
export type { RadioButtonProps } from './RadioButton';
export type { RadioGroupProps } from './RadioGroup';
export type { RichCheckboxProps } from './RichCheckbox';
export type { MultiSelectProps, SelectProps } from './Select';
export type { TextareaProps } from './Textarea';
77 changes: 77 additions & 0 deletions src/components/input/test/RichCheckbox-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { checkAccessibility, mount } from '@hypothesis/frontend-testing';

import RichCheckbox from '../RichCheckbox';

describe('RichCheckbox', () => {
let fakeOnChange;

beforeEach(() => {
fakeOnChange = sinon.stub();
});

function createComponent(props = {}) {
return mount(
<RichCheckbox checked onChange={fakeOnChange} {...props}>
This is child content
</RichCheckbox>,
);
}

const getCheckbox = wrapper => wrapper.find('[role="checkbox"]');

[
{ checked: false, expectedIcon: 'CheckboxIcon' },
{ checked: true, expectedIcon: 'CheckboxCheckedFilledIcon' },
].forEach(({ checked, expectedIcon }) => {
it('shows the right icon depending on the checked state', () => {
const wrapper = createComponent({ checked });
assert.isTrue(wrapper.exists(expectedIcon));
});

it('sets matching checked prop and aria-checked attribute', () => {
const wrapper = createComponent({ checked });
assert.equal(getCheckbox(wrapper).prop('aria-checked'), checked);
});

it('calls onChange when clicked', () => {
const wrapper = createComponent({ checked });

assert.notCalled(fakeOnChange);
getCheckbox(wrapper).simulate('click');
assert.calledWith(fakeOnChange, !checked);
});
});

['Enter', ' '].forEach(key => {
it('calls onChange when Enter or Space are pressed', () => {
const wrapper = createComponent();

assert.notCalled(fakeOnChange);
getCheckbox(wrapper).simulate('keydown', { key });
assert.calledWith(fakeOnChange, false);
});
});

it('does not call onChange when keys other than Enter or Space are pressed', () => {
const wrapper = createComponent();

getCheckbox(wrapper).simulate('keydown', { key: 'A' });
assert.notCalled(fakeOnChange);
});

[{ subtitle: 'Hello world' }, { subtitle: undefined }].forEach(
({ subtitle }) => {
it('shows subtitle only when provided', () => {
const wrapper = createComponent({ subtitle });
assert.equal(wrapper.exists('[data-testid="subtitle"]'), !!subtitle);
});
},
);

it(
'should pass a11y checks',
checkAccessibility({
content: createComponent,
}),
);
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export {
OptionButton,
RadioButton,
RadioGroup,
RichCheckbox,
Select,
Textarea,
} from './components/input';
Expand Down Expand Up @@ -133,6 +134,7 @@ export type {
OptionButtonProps,
RadioButtonProps,
RadioGroupProps,
RichCheckboxProps,
SelectProps,
TextareaProps,
} from './components/input';
Expand Down
110 changes: 110 additions & 0 deletions src/pattern-library/components/patterns/input/RichCheckboxPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useState } from 'preact/hooks';

import type { RichCheckboxProps } from '../../../../components/input/RichCheckbox';
import RichCheckbox from '../../../../components/input/RichCheckbox';
import Library from '../../Library';

function RichCheckbox_({
children,
initialChecked = false,
subtitle,
}: Omit<RichCheckboxProps, 'checked' | 'onChange'> & {
initialChecked?: boolean;
}) {
const [checked, setChecked] = useState(initialChecked);

return (
<RichCheckbox checked={checked} onChange={setChecked} subtitle={subtitle}>
{children}
</RichCheckbox>
);
}

export default function CheckboxPage() {
return (
<Library.Page
title="RichCheckbox"
intro={
<>
<p>
<code>RichCheckbox</code> is an opinionated controlled{' '}
<code>
[role={'"'}checkbox{'"'}]
</code>{' '}
component that includes a checkbox icon next to some content.
</p>
<p>
It has predefined hover and checked styles, like the individual{' '}
<code>Radio</code>s inside{' '}
<Library.Link href="/input-radio-group">
<code>RadioGroup</code>
</Library.Link>
.
</p>
</>
}
>
<Library.SectionL2>
<Library.Usage symbolName="RichCheckbox" />
<Library.SectionL3>
<Library.Demo title="Basic RichCheckbox" withSource>
<div className="flex flex-col gap-y-2">
<RichCheckbox_>Click me</RichCheckbox_>
<RichCheckbox_ subtitle="This one includes a subtitle">
Click me
</RichCheckbox_>
</div>
</Library.Demo>
</Library.SectionL3>
</Library.SectionL2>

<Library.SectionL2 title="Component API">
<Library.SectionL3 title="checked">
<Library.Info>
<Library.InfoItem label="description">
Set whether the <code>RichCheckbox</code> is checked.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{`boolean`}</code>
</Library.InfoItem>
</Library.Info>
</Library.SectionL3>
<Library.SectionL3 title="children">
<Library.Info>
<Library.InfoItem label="description">
Main content of the checkbox. Will be displayed next to check
checkbox icon, and vertically aligned with it.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{`ComponentChildren`}</code>
</Library.InfoItem>
</Library.Info>
</Library.SectionL3>
<Library.SectionL3 title="onChange">
<Library.Info>
<Library.InfoItem label="description">
Callback invoked check the <code>checked</code> value changes.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{`(checked: boolean) => void`}</code>
</Library.InfoItem>
</Library.Info>
</Library.SectionL3>
<Library.SectionL3 title="subtitle">
<Library.Info>
<Library.InfoItem label="description">
If provided, it will show extra content in a lighter font color,
right below the main content, and aligned to the left with it.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{`ComponentChildren`}</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>{`undefined`}</code>
</Library.InfoItem>
</Library.Info>
</Library.SectionL3>
</Library.SectionL2>
</Library.Page>
);
}
7 changes: 7 additions & 0 deletions src/pattern-library/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import InputGroupPage from './components/patterns/input/InputGroupPage';
import InputPage from './components/patterns/input/InputPage';
import OptionButtonPage from './components/patterns/input/OptionButtonPage';
import RadioGroupPage from './components/patterns/input/RadioGroupPage';
import RichCheckboxPage from './components/patterns/input/RichCheckboxPage';
import SelectPage from './components/patterns/input/SelectPage';
import TextareaPage from './components/patterns/input/TextareaPage';
import CardPage from './components/patterns/layout/CardPage';
Expand Down Expand Up @@ -208,6 +209,12 @@ const routes: PlaygroundRoute[] = [
component: RadioGroupPage,
route: '/input-radio-group',
},
{
title: 'RichCheckbox',
group: 'input',
component: RichCheckboxPage,
route: '/input-rich-checkbox',
},
{
title: 'Selects',
group: 'input',
Expand Down