Skip to content

Commit

Permalink
Merge pull request #136 from fortanix/mkrause/250211-split-checkbox
Browse files Browse the repository at this point in the history
Split Checkbox component into a boolean Checkbox and a tri-state CheckboxTri (which also allows indeterminate state)
  • Loading branch information
mkrause authored Feb 13, 2025
2 parents 82ee10e + 62a4dab commit 8b96d7c
Show file tree
Hide file tree
Showing 9 changed files with 330 additions and 114 deletions.
6 changes: 5 additions & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ const preview = {
'controls',
[
'Checkbox',
[
'CheckboxTri',
],
'Switch',
'Radio',
'SegmentedControl',
Expand Down Expand Up @@ -208,7 +211,8 @@ const preview = {
// Format: { id: <rule-name>, enabled: <boolean>, selector: <css-selector> }
rules: [
// Known accessibility issues (need to fix these)
//{ id: 'color-contrast', selector: '*:not(button[class*=primary])' },
//{ id: 'color-contrast', enabled: false, selector: '*:not(button[class*=primary])' },
{ id: 'color-contrast', enabled: false, selector: '*' },
],
},
// Axe's options parameter
Expand Down
1 change: 1 addition & 0 deletions app/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export { SubmitButton } from '../src/components/forms/context/SubmitButton/Submi

// Forms > Controls
export { Checkbox } from '../src/components/forms/controls/Checkbox/Checkbox.tsx';
export { CheckboxTri } from '../src/components/forms/controls/Checkbox/CheckboxTri.tsx';
export { DatePicker } from '../src/components/forms/controls/DatePicker/DatePicker.tsx';
export { DatePickerRange } from '../src/components/forms/controls/DatePickerRange/DatePickerRange.tsx';
export { Input } from '../src/components/forms/controls/Input/Input.tsx';
Expand Down
93 changes: 58 additions & 35 deletions src/components/forms/controls/Checkbox/Checkbox.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,64 +4,87 @@

@use '../../../../styling/defs.scss' as bk;

@mixin svg-checkbox {
background-image: url('data:image/svg+xml;utf8,<svg width="14" height="10" viewBox="0 0 14 10" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M5 10L0 5.19231L1.4 3.84615L5 7.30769L12.6 0L14 1.34615L5 10Z" fill="white"/></svg>');
@mixin bk-checkbox-checkmark {
&::after {
width: 42%;
height: 68%;
margin-top: 0;
border-right: bk.rem-from-px(2) solid currentColor;
border-bottom: bk.rem-from-px(2) solid currentColor;
transform: translate(0, -15%) scaleY(.95) rotate(44deg);
}
}

@mixin svg-dash {
background-image: url('data:image/svg+xml;utf8,<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M2.97165 9.94239L2.97165 8.00003L2.97266 8.00103L14.9717 7.99997L14.9719 9.94239L2.97165 9.94239Z" fill="white"/></svg>');
@mixin bk-checkbox-dash {
&::after {
width: 60%;
height: 0;
margin-top: 0;
border-top: bk.rem-from-px(2) solid currentColor;
}
}

@layer baklava.components {
.bk-checkbox {
@include bk.component-base(bk-checkbox);


$bk-checkbox-transition-duration: 120ms;

cursor: pointer;

appearance: none;
width: 18px;
flex-shrink: 0;

appearance: none; // Disable native user agent appearance
width: bk.$spacing-5;
aspect-ratio: 1;
border-radius: 3px;


background: transparent;
background-position: top; /* Transition background-image from top */
border: 1px solid bk.$theme-checkbox-border-default;

border: bk.rem-from-px(1) solid bk.$theme-checkbox-border-default;
border-radius: bk.$radius-1;
&:checked, &:indeterminate {
border: none;
background-color: bk.$theme-checkbox-background-default;
background-position: center;
background-repeat: no-repeat;
border: none;
}
&:checked {
@include svg-checkbox;
}
&:indeterminate {
@include svg-dash;
&:not(:disabled):active {
scale: 0.95; // Give the checkbox a subtle bounce effect when pressing down
}

&:disabled {
border-color: bk.$theme-checkbox-border-disabled;
background-color: transparent;
cursor: not-allowed;

background-color: transparent;
border-color: bk.$theme-checkbox-border-disabled;
color: bk.$theme-checkbox-checkmark-disabled;

&:checked, &:indeterminate {
background-color: bk.$theme-checkbox-background-non-active;
}
&:checked {
@include svg-checkbox;
}
&:indeterminate {
@include svg-dash;
}
}
&:focus-visible, &.pseudo-focused {
outline: 2px solid bk.$theme-checkbox-border-focus !important;
outline-offset: 0 !important;
}

@media (prefers-reduced-motion: no-preference) {
transition: none 100ms ease-out;
transition-property: background-color, background-position, border-color;
transition: none $bk-checkbox-transition-duration ease-out;
transition-property: scale, background-color, border-color;
}

//
// Draw the checkmark
//

display: inline-grid;
color: bk.$theme-checkbox-checkmark-default;

&::after {
content: '';
place-self: center;
display: block;
margin-top: -25%; // Transition: start from top and move to center on activation

@media (prefers-reduced-motion: no-preference) {
transition: margin-top $bk-checkbox-transition-duration ease-out;
}
}
&:checked:not(:indeterminate) { @include bk-checkbox-checkmark; }
&:indeterminate { @include bk-checkbox-dash; }
}
}
96 changes: 43 additions & 53 deletions src/components/forms/controls/Checkbox/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import * as React from 'react';

import { Checkbox } from './Checkbox.tsx';

import cl from './Checkbox.module.scss';


type CheckboxArgs = React.ComponentProps<typeof Checkbox>;
type Story = StoryObj<CheckboxArgs>;
Expand All @@ -32,90 +30,82 @@ export default {
} satisfies Meta<CheckboxArgs>;


export const Unchecked: Story = {
args: {},
};

export const Checked: Story = {
args: { defaultChecked: true },
export const CheckboxStandard: Story = {
name: 'Checkbox',
};

export const Indeterminate: Story = {
export const CheckboxChecked: Story = {
name: 'Checkbox [checked]',
args: {
defaultChecked: false,
indeterminate: true,
defaultChecked: true,
},
};

export const DisabledUnchecked: Story = {
name: 'Disabled (unchecked)',
args: { disabled: true },
};

export const DisabledChecked: Story = {
name: 'Disabled (checked)',
export const CheckboxDisabled: Story = {
name: 'Checkbox [disabled]',
args: {
defaultChecked: true,
disabled: true,
},
};

export const DisabledIndeterminate: Story = {
name: 'Disabled (indeterminate)',
export const CheckboxDisabledChecked: Story = {
name: 'Checkbox [disabled] [checked]',
args: {
defaultChecked: false,
disabled: true,
indeterminate: true,
defaultChecked: true,
},
};

export const FocusedUnchecked: Story = {
name: 'Focused (unchecked)',
export const CheckboxFocused: Story = {
name: 'Checkbox [focused]',
args: {
className: cl['pseudo-focused'],
className: 'pseudo-focus-visible',
},
};

export const FocusedChecked: Story = {
name: 'Focused (checked)',
export const CheckboxFocusedChecked: Story = {
name: 'Checkbox [focused] [checked]',
args: {
className: cl['pseudo-focused'],
className: 'pseudo-focus-visible',
defaultChecked: true,
},
};

export const FocusedIndeterminate: Story = {
name: 'Focused (indeterminate)',
export const CheckboxFocusedDisabled: Story = {
name: 'Checkbox [focused] [disabled]',
args: {
className: cl['pseudo-focused'],
defaultChecked: false,
indeterminate: true,
},
};

export const FocusedDisabledUnchecked: Story = {
name: 'Focused & Disabled (unchecked)',
args: {
className: cl['pseudo-focused'],
className: 'pseudo-focus-visible',
disabled: true,
},
};

export const FocusedDisabledChecked: Story = {
name: 'Focused & Disabled (checked)',
export const CheckboxFocusedDisabledChecked: Story = {
name: 'Checkbox [focused] [disabled] [checked]',
args: {
className: cl['pseudo-focused'],
defaultChecked: true,
className: 'pseudo-focus-visible',
disabled: true,
defaultChecked: true,
},
};

export const FocusedDisabledIndeterminate: Story = {
name: 'Focused & Disabled (indeterminate)',
args: {
className: cl['pseudo-focused'],
defaultChecked: false,
disabled: true,
indeterminate: true,
},

const CheckboxControlled = (args: CheckboxArgs) => {
const [checked, setChecked] = React.useState<CheckboxArgs['checked']>(args.defaultChecked ?? false);

return (
<div style={{ textAlign: 'center' }}>
<Checkbox
{...args}
defaultChecked={undefined} // `defaultChecked` must be `undefined` for controlled components
checked={checked}
onUpdate={checked => { setChecked(checked); }}
/>
{' '}
<p>Current state: {String(checked)}</p>
</div>
);
};
export const CheckboxControlledStory: Story = {
name: 'Checkbox (controlled)',
render: (args) => <CheckboxControlled {...args} defaultChecked/>,
};
44 changes: 22 additions & 22 deletions src/components/forms/controls/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,52 @@
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { classNames as cx, type ComponentProps } from '../../../../util/componentUtil.ts';
import * as React from 'react';
import { classNames as cx, type ComponentProps } from '../../../../util/componentUtil.ts';

import cl from './Checkbox.module.scss';


export { cl as CheckboxClassNames };

export type CheckboxProps = ComponentProps<'input'> & {
export type CheckboxState = boolean;

export type CheckboxProps = Omit<ComponentProps<'input'>, 'checked' | 'defaultChecked'> & {
/** Whether this component should be unstyled. */
unstyled?: undefined | boolean,

/** Whether the checkbox is in indeterminate state (minus sign) */
indeterminate?: undefined | boolean,

/** The default state of the checkbox at initialization time. Default: undefined. */
defaultChecked?: undefined | CheckboxState,

/**
* The current state of the checkbox. If `undefined`, the component is considered uncontrolled.
*/
checked?: undefined | CheckboxState,

/** Callback for update events, will be called with the new state of the checkbox. */
onUpdate?: undefined | ((checked: CheckboxState) => void),
};
/**
* A simple Checkbox control, just the &lt;input type="checkbox"&gt; and nothing else.
* A checkbox control is a basic on/off toggle.
*/
export const Checkbox = (props: CheckboxProps) => {
const {
unstyled = false,
indeterminate = false,
...propsRest
} = props;

const checkboxRef = React.useRef<React.ComponentRef<'input'>>(null);
const { unstyled = false, ...propsRest } = props;

React.useEffect(() => {
if (checkboxRef?.current) {
if (indeterminate) {
checkboxRef.current.checked = false;
}
checkboxRef.current.indeterminate = indeterminate;
}
}, [indeterminate]);
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
props.onChange?.(event);
props.onUpdate?.(event.target.checked);
}, [props.onChange, props.onUpdate]);

return (
<input
type="checkbox"
ref={checkboxRef}
{...propsRest}
className={cx(
'bk',
{ [cl['bk-checkbox']]: !unstyled },
propsRest.className,
)}
onChange={handleChange}
/>
);
};
Loading

0 comments on commit 8b96d7c

Please sign in to comment.