Skip to content

Commit cfc0f30

Browse files
author
Alejandro Celaya
committed
Add RichCheckbox component
1 parent ea06dec commit cfc0f30

4 files changed

Lines changed: 269 additions & 0 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import classnames from 'classnames';
2+
import type { ComponentChildren } from 'preact';
3+
import { useCallback } from 'preact/hooks';
4+
5+
import { CheckboxCheckedFilledIcon, CheckboxIcon } from '../icons';
6+
7+
export type RichCheckboxProps = {
8+
checked: boolean;
9+
onChange: (checked: boolean) => void;
10+
11+
/**
12+
* Content provided as children is vertically aligned with the checkbox icon
13+
*/
14+
children: ComponentChildren;
15+
16+
/**
17+
* Allows to provide extra content to be displayed under the children, in a
18+
* smaller and more subtle font color.
19+
*/
20+
subtitle?: ComponentChildren;
21+
};
22+
23+
/**
24+
* An opinionated `[role="checkbox"]` component which displays a checkbox icon
25+
* next to provided content.
26+
*
27+
* If a `subtitle` is provided, it will be shown in a lighter color right under
28+
* the main content, and aligned on the left with it.
29+
*/
30+
export default function RichCheckbox({
31+
checked,
32+
onChange,
33+
children,
34+
subtitle,
35+
}: RichCheckboxProps) {
36+
const toggle = useCallback(() => onChange(!checked), [checked, onChange]);
37+
38+
return (
39+
<div
40+
className={classnames(
41+
'group focus-visible-ring',
42+
'grid gap-x-1.5 items-center grid-cols-[auto_1fr]',
43+
'px-3 py-2 rounded-lg cursor-pointer',
44+
'hover:bg-grey-3/25 aria-checked:bg-grey-3/50',
45+
)}
46+
role="checkbox"
47+
aria-checked={checked}
48+
onClick={toggle}
49+
onKeyDown={e => {
50+
if (['Enter', ' '].includes(e.key)) {
51+
e.preventDefault();
52+
toggle();
53+
}
54+
}}
55+
tabIndex={0}
56+
>
57+
{!checked && <CheckboxIcon />}
58+
{checked && <CheckboxCheckedFilledIcon />}
59+
<p className="text-grey-7 group-hover:text-grey-8 group-aria-checked:text-grey-8">
60+
{children}
61+
</p>
62+
{subtitle && (
63+
<>
64+
<div />
65+
<p
66+
data-testid="subtitle"
67+
className="text-grey-6 group-hover:text-grey-7 group-aria-checked:text-grey-7"
68+
>
69+
{subtitle}
70+
</p>
71+
</>
72+
)}
73+
</div>
74+
);
75+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { checkAccessibility, mount } from '@hypothesis/frontend-testing';
2+
3+
import RichCheckbox from '../RichCheckbox';
4+
5+
describe('RichCheckbox', () => {
6+
let fakeOnChange;
7+
8+
beforeEach(() => {
9+
fakeOnChange = sinon.stub();
10+
});
11+
12+
function createComponent(props = {}) {
13+
return mount(
14+
<RichCheckbox checked onChange={fakeOnChange} {...props}>
15+
This is child content
16+
</RichCheckbox>,
17+
);
18+
}
19+
20+
const getCheckbox = wrapper => wrapper.find('[role="checkbox"]');
21+
22+
[
23+
{ checked: false, expectedIcon: 'CheckboxIcon' },
24+
{ checked: true, expectedIcon: 'CheckboxCheckedFilledIcon' },
25+
].forEach(({ checked, expectedIcon }) => {
26+
it('shows the right icon depending on the checked state', () => {
27+
const wrapper = createComponent({ checked });
28+
assert.isTrue(wrapper.exists(expectedIcon));
29+
});
30+
31+
it('sets matching checked prop and aria-checked attribute', () => {
32+
const wrapper = createComponent({ checked });
33+
assert.equal(getCheckbox(wrapper).prop('aria-checked'), checked);
34+
});
35+
36+
it('calls onChange when clicked', () => {
37+
const wrapper = createComponent({ checked });
38+
39+
assert.notCalled(fakeOnChange);
40+
getCheckbox(wrapper).simulate('click');
41+
assert.calledWith(fakeOnChange, !checked);
42+
});
43+
});
44+
45+
['Enter', ' '].forEach(key => {
46+
it('calls onChange when Enter or Space are pressed', () => {
47+
const wrapper = createComponent();
48+
49+
assert.notCalled(fakeOnChange);
50+
getCheckbox(wrapper).simulate('keydown', { key });
51+
assert.calledWith(fakeOnChange, false);
52+
});
53+
});
54+
55+
it('does not call onChange when keys other than Enter or Space are pressed', () => {
56+
const wrapper = createComponent();
57+
58+
getCheckbox(wrapper).simulate('keydown', { key: 'A' });
59+
assert.notCalled(fakeOnChange);
60+
});
61+
62+
[{ subtitle: 'Hello world' }, { subtitle: undefined }].forEach(
63+
({ subtitle }) => {
64+
it('shows subtitle only when provided', () => {
65+
const wrapper = createComponent({ subtitle });
66+
assert.equal(wrapper.exists('[data-testid="subtitle"]'), !!subtitle);
67+
});
68+
},
69+
);
70+
71+
it(
72+
'should pass a11y checks',
73+
checkAccessibility({
74+
content: createComponent,
75+
}),
76+
);
77+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useState } from 'preact/hooks';
2+
3+
import type { RichCheckboxProps } from '../../../../components/input/RichCheckbox';
4+
import RichCheckbox from '../../../../components/input/RichCheckbox';
5+
import Library from '../../Library';
6+
7+
function RichCheckbox_({
8+
children,
9+
initialChecked = false,
10+
subtitle,
11+
}: Omit<RichCheckboxProps, 'checked' | 'onChange'> & {
12+
initialChecked?: boolean;
13+
}) {
14+
const [checked, setChecked] = useState(initialChecked);
15+
16+
return (
17+
<RichCheckbox checked={checked} onChange={setChecked} subtitle={subtitle}>
18+
{children}
19+
</RichCheckbox>
20+
);
21+
}
22+
23+
export default function CheckboxPage() {
24+
return (
25+
<Library.Page
26+
title="RichCheckbox"
27+
intro={
28+
<>
29+
<p>
30+
<code>RichCheckbox</code> is an opinionated controlled{' '}
31+
<code>
32+
[role={'"'}checkbox{'"'}]
33+
</code>{' '}
34+
component that includes a checkbox icon next to some content.
35+
</p>
36+
<p>
37+
It has predefined hover and checked styles, like the individual{' '}
38+
<code>Radio</code>s inside{' '}
39+
<Library.Link href="/input-radio-group">
40+
<code>RadioGroup</code>
41+
</Library.Link>
42+
.
43+
</p>
44+
</>
45+
}
46+
>
47+
<Library.SectionL2>
48+
<Library.Usage symbolName="RichCheckbox" />
49+
<Library.SectionL3>
50+
<Library.Demo title="Basic RichCheckbox" withSource>
51+
<div className="flex flex-col gap-y-2">
52+
<RichCheckbox_>Click me</RichCheckbox_>
53+
<RichCheckbox_ subtitle="This one includes a subtitle">
54+
Click me
55+
</RichCheckbox_>
56+
</div>
57+
</Library.Demo>
58+
</Library.SectionL3>
59+
</Library.SectionL2>
60+
61+
<Library.SectionL2 title="Component API">
62+
<Library.SectionL3 title="checked">
63+
<Library.Info>
64+
<Library.InfoItem label="description">
65+
Set whether the <code>RichCheckbox</code> is checked.
66+
</Library.InfoItem>
67+
<Library.InfoItem label="type">
68+
<code>{`boolean`}</code>
69+
</Library.InfoItem>
70+
</Library.Info>
71+
</Library.SectionL3>
72+
<Library.SectionL3 title="children">
73+
<Library.Info>
74+
<Library.InfoItem label="description">
75+
Main content of the checkbox. Will be displayed next to check
76+
checkbox icon, and vertically aligned with it.
77+
</Library.InfoItem>
78+
<Library.InfoItem label="type">
79+
<code>{`ComponentChildren`}</code>
80+
</Library.InfoItem>
81+
</Library.Info>
82+
</Library.SectionL3>
83+
<Library.SectionL3 title="onChange">
84+
<Library.Info>
85+
<Library.InfoItem label="description">
86+
Callback invoked check the <code>checked</code> value changes.
87+
</Library.InfoItem>
88+
<Library.InfoItem label="type">
89+
<code>{`(checked: boolean) => void`}</code>
90+
</Library.InfoItem>
91+
</Library.Info>
92+
</Library.SectionL3>
93+
<Library.SectionL3 title="subtitle">
94+
<Library.Info>
95+
<Library.InfoItem label="description">
96+
If provided, it will show extra content in a lighter font color,
97+
right below the main content, and aligned to the left with it.
98+
</Library.InfoItem>
99+
<Library.InfoItem label="type">
100+
<code>{`ComponentChildren`}</code>
101+
</Library.InfoItem>
102+
<Library.InfoItem label="default">
103+
<code>{`undefined`}</code>
104+
</Library.InfoItem>
105+
</Library.Info>
106+
</Library.SectionL3>
107+
</Library.SectionL2>
108+
</Library.Page>
109+
);
110+
}

src/pattern-library/routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import InputGroupPage from './components/patterns/input/InputGroupPage';
2424
import InputPage from './components/patterns/input/InputPage';
2525
import OptionButtonPage from './components/patterns/input/OptionButtonPage';
2626
import RadioGroupPage from './components/patterns/input/RadioGroupPage';
27+
import RichCheckboxPage from './components/patterns/input/RichCheckboxPage';
2728
import SelectPage from './components/patterns/input/SelectPage';
2829
import TextareaPage from './components/patterns/input/TextareaPage';
2930
import CardPage from './components/patterns/layout/CardPage';
@@ -208,6 +209,12 @@ const routes: PlaygroundRoute[] = [
208209
component: RadioGroupPage,
209210
route: '/input-radio-group',
210211
},
212+
{
213+
title: 'RichCheckbox',
214+
group: 'input',
215+
component: RichCheckboxPage,
216+
route: '/input-rich-checkbox',
217+
},
211218
{
212219
title: 'Selects',
213220
group: 'input',

0 commit comments

Comments
 (0)