Skip to content

Commit

Permalink
feat: add checkbox
Browse files Browse the repository at this point in the history
  • Loading branch information
tigranpetrossian committed Jun 30, 2024
1 parent 206cbdd commit 4b61f88
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 0 deletions.
84 changes: 84 additions & 0 deletions figma-kit/src/components/checkbox/checkbox.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
.fp-CheckboxRoot {
position: relative;
display: grid;
grid-template-columns: var(--space-4) auto;
min-height: 24px;
gap: var(--space-1) var(--space-2);
}

.fp-CheckboxInput {
all: unset;
box-sizing: border-box;
display: block;
width: var(--space-4);
height: var(--space-4);
margin: var(--space-1) 0;
background-color: transparent;
border: 1px solid var(--figma-color-border-strong);
border-radius: var(--radius-medium);
flex-shrink: 0;

&:focus-visible {
outline-offset: -1px;
outline: 1px solid var(--figma-color-border-selected);
}

&:focus-visible:checked {
outline-offset: 0;
outline: 1px solid var(--figma-color-border-selected-strong);
border-color: var(--figma-color-icon-onbrand);
}

&:checked {
background-color: var(--figma-color-bg-brand);
border-color: transparent;
}

&:disabled {
border-color: var(--figma-color-border-disabled-strong);
}

&:disabled:checked {
border-color: transparent;
background-color: var(--figma-color-border-disabled-strong);
}
}

.fp-CheckboxIndicator {
display: block;
pointer-events: none;
position: absolute;
top: var(--space-1);
}

.fp-CheckboxCheckmark,
.fp-CheckboxIndeterminate {
display: none;
}

.fp-CheckboxInput:checked ~ .fp-CheckboxIndicator .fp-CheckboxCheckmark {
color: var(--figma-color-icon-onbrand);
display: block;
}

.fp-CheckboxInput:indeterminate ~ .fp-CheckboxIndicator .fp-CheckboxIndeterminate {
color: var(--figma-color-icon);
display: block;
}
.fp-CheckboxInput:disabled:indeterminate ~ .fp-CheckboxIndicator .fp-CheckboxIndeterminate {
color: var(--figma-color-icon-disabled);
display: block;
}

.fp-CheckboxLabel {
margin-top: var(--space-1);
}

.fp-CheckboxInput:disabled ~ .fp-CheckboxLabel {
color: var(--figma-color-text-disabled);
}

.fp-CheckboxDescription {
color: var(--figma-color-text-secondary);
grid-area: 2 / 2;
}
99 changes: 99 additions & 0 deletions figma-kit/src/components/checkbox/checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as Checkbox from './checkbox';

type Story = StoryObj<typeof Checkbox.Root>;

const meta: Meta<typeof Checkbox.Root> = {
title: 'Components/Checkbox',
component: Checkbox.Root,
};

export default meta;

export const WithLabel: Story = {
render: () => {
return (
<Checkbox.Root>
<Checkbox.Input />
<Checkbox.Label>Clip content</Checkbox.Label>
</Checkbox.Root>
);
},
};

export const WithoutLabel: Story = {
render: () => {
return (
<Checkbox.Root>
<Checkbox.Input />
</Checkbox.Root>
);
},
};

export const Indeterminate: Story = {
render: () => {
return (
<Checkbox.Root>
<Checkbox.Input indeterminate />
<Checkbox.Label>Clip content</Checkbox.Label>
</Checkbox.Root>
);
},
};

export const Disabled: Story = {
render: () => {
return (
<Checkbox.Root>
<Checkbox.Input disabled />
<Checkbox.Label>Clip content</Checkbox.Label>
</Checkbox.Root>
);
},
};
export const DisabledChecked: Story = {
render: () => {
return (
<Checkbox.Root>
<Checkbox.Input disabled checked />
<Checkbox.Label>Clip content</Checkbox.Label>
</Checkbox.Root>
);
},
};
export const DisabledIndeterminate: Story = {
render: () => {
return (
<Checkbox.Root>
<Checkbox.Input disabled indeterminate />
<Checkbox.Label>Clip content</Checkbox.Label>
</Checkbox.Root>
);
},
};

export const MultiLineLabel: Story = {
render: () => {
return (
<Checkbox.Root style={{ width: 128 }}>
<Checkbox.Input />
<Checkbox.Label>Clip content with label that spans multiple lines</Checkbox.Label>
</Checkbox.Root>
);
},
};

export const Description: Story = {
render: () => {
return (
<Checkbox.Root style={{ maxWidth: 512 }}>
<Checkbox.Input />
<Checkbox.Label>Checkbox with description</Checkbox.Label>
<Checkbox.Description>
Helpful description of the option which may briefly highlight side effects or conditions of the option.
</Checkbox.Description>
</Checkbox.Root>
);
},
};
112 changes: 112 additions & 0 deletions figma-kit/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React, { useCallback, useId } from 'react';
import { cx } from 'class-variance-authority';
import { Text, Label as LabelPrimitive } from '@components/text';
import { useComposedRefs } from '@lib/react/use-compose-refs';
import { CheckmarkIcon } from '@components/icons';
import { CheckmarkIndeterminateIcon } from '@components/icons/checkmark-indeterminate';
import { createContext } from '@lib/react/create-context';

const [CheckboxContextProvider, useCheckboxContext] = createContext<{ id: string }>('Checkbox');

type RootElement = React.ElementRef<'div'>;
type RootProps = React.ComponentPropsWithoutRef<'div'>;

const Root = React.forwardRef<RootElement, RootProps>((props, ref) => {
const { className, id: idProp, ...rootProps } = props;
const generatedId = useId();
const id = idProp ?? generatedId;

return (
<CheckboxContextProvider id={id}>
<div ref={ref} className={cx(className, 'fp-CheckboxRoot')} {...rootProps} />
</CheckboxContextProvider>
);
});

type CheckboxElement = React.ElementRef<'input'>;
type CheckboxProps = React.ComponentPropsWithoutRef<'input'> & {
indeterminate?: boolean;
};

const Input = React.forwardRef<CheckboxElement, CheckboxProps>((props, forwardedRef) => {
const { className, indeterminate, ...checkboxProps } = props;
const { id } = useCheckboxContext('Input');
const inputRef = useIndeterminateState(indeterminate);
const ref = useComposedRefs(forwardedRef, inputRef);

return (
<>
<input
ref={ref}
className={cx(className, 'fp-CheckboxInput')}
id={id}
// VoiceOver on Chrome does not announce the label when simply using htmlFor
aria-labelledby={`checkbox-label-${id}`}
aria-describedby={`checkbox-description-${id}`}
{...checkboxProps}
type="checkbox"
/>
<Indicator />
</>
);
});

const Indicator = () => {
return (
<span className="fp-CheckboxIndicator" aria-hidden="true">
<CheckmarkIcon className="fp-CheckboxCheckmark" size="4" />
<CheckmarkIndeterminateIcon className="fp-CheckboxIndeterminate" size="4" />
</span>
);
};

type LabelElement = React.ElementRef<'label'>;
type LabelProps = React.ComponentPropsWithoutRef<'label'>;

const Label = React.forwardRef<LabelElement, LabelProps>((props, ref) => {
const { className, ...labelProps } = props;
const { id } = useCheckboxContext('Input');
return (
<LabelPrimitive
aria-hidden="true"
ref={ref}
className={cx(className, 'fp-CheckboxLabel')}
htmlFor={id}
id={`checkbox-label-${id}`}
{...labelProps}
/>
);
});

type DescriptionElement = React.ElementRef<'label'>;
type DescriptionProps = React.ComponentPropsWithoutRef<'label'>;

const Description = React.forwardRef<DescriptionElement, DescriptionProps>((props, ref) => {
const { className, ...desriptionProps } = props;
const { id } = useCheckboxContext('Description');
return (
<Text
aria-hidden="true"
ref={ref}
className={cx(className, 'fp-CheckboxDescription')}
id={`checkbox-description-${id}`}
{...desriptionProps}
/>
);
});

function useIndeterminateState(indeterminate: boolean | undefined) {
return useCallback(
(inputElement: HTMLInputElement) => {
if (!inputElement) {
return;
}

inputElement.indeterminate = !!indeterminate;
},
[indeterminate]
);
}

export type { CheckboxProps };
export { Root, Input, Label, Description };
1 change: 1 addition & 0 deletions figma-kit/src/components/checkbox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './checkbox';
35 changes: 35 additions & 0 deletions figma-kit/src/lib/react/create-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* @license
* MIT License
* Copyright (c) 2022 WorkOS
* https://github.com/radix-ui/primitives/blob/main/LICENSE
* */
import React from 'react';

function createContext<ContextValueType extends object | null>(
rootComponentName: string,
defaultContext?: ContextValueType
) {
const Context = React.createContext<ContextValueType | undefined>(defaultContext);

function Provider(props: ContextValueType & { children: React.ReactNode }) {
const { children, ...context } = props;
// Only re-memoize when prop values change
// eslint-disable-next-line react-hooks/exhaustive-deps
const value = React.useMemo(() => context, Object.values(context)) as ContextValueType;
return <Context.Provider value={value}>{children}</Context.Provider>;
}

function useContext(consumerName: string) {
const context = React.useContext(Context);
if (context) return context;
if (defaultContext !== undefined) return defaultContext;
// if a defaultContext wasn't specified, it's a required context.
throw new Error(`\`${consumerName}\` must be used within \`${rootComponentName}\``);
}

Provider.displayName = rootComponentName + 'Provider';
return [Provider, useContext] as const;
}

export { createContext };
1 change: 1 addition & 0 deletions figma-kit/src/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@
@import '../components/dialog/dialog.css';
@import '../components/alert-dialog/alert-dialog.css';
@import '../components/tabs/tabs.css';
@import '../components/checkbox/checkbox.css';

0 comments on commit 4b61f88

Please sign in to comment.