Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/smart-mammals-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/ds-components": patch
---

Added CheckboxGroup and RadioGroup components
2 changes: 1 addition & 1 deletion apps/hash-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"dependencies": {
"@ai-sdk/openai": "3.0.63",
"@apollo/client": "3.10.5",
"@ark-ui/react": "5.26.2",
"@ark-ui/react": "5.37.2",
"@blockprotocol/core": "0.1.5",
"@blockprotocol/graph": "workspace:*",
"@blockprotocol/hook": "0.1.8",
Expand Down
4 changes: 2 additions & 2 deletions libs/@hashintel/ds-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"test:unit:watch": "yarn build:buildinfo && vitest --exclude tests/snapshots.spec.ts"
},
"dependencies": {
"@ark-ui/react": "5.26.2",
"@ark-ui/react": "5.37.2",
"@hashintel/ds-helpers": "workspace:^",
"@pandacss/dev": "1.11.1",
"@pandacss/preset-panda": "1.11.1",
Expand Down Expand Up @@ -116,7 +116,7 @@
"zod": "4.4.3"
},
"peerDependencies": {
"@ark-ui/react": "^5.26.2",
"@ark-ui/react": "^5.37.2",
"@hashintel/ds-helpers": "workspace:^",
"react": "^19.2.0",
"react-dom": "^19.2.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export const styles = sva({
},
labelPlacement: {
left: {
root: { flexDirection: "row-reverse" },
root: { flexDirection: "row-reverse", justifyContent: "space-between" },
},
right: {},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Checkbox as BaseCheckbox } from "@ark-ui/react/checkbox";

import { cx } from "@hashintel/ds-helpers/css";

import { useFieldId } from "../Form/field-id-context";
import { styles } from "./checkbox.recipe";

import type { SharedInputProps, Tone } from "../../util/form-shared";
Expand All @@ -20,6 +19,7 @@ export const Checkbox = ({
invalid,
testId,
htmlForId,
htmlValue,
ref,
inputRef,
autoFocus,
Expand All @@ -40,10 +40,10 @@ export const Checkbox = ({
tone?: Exclude<Tone, "error"> | "success";
/** Render the box in the indeterminate ("partially checked") state */
indeterminate?: boolean;
/** An optional value used for native form submissions */
htmlValue?: string;
} & SharedInputProps<HTMLInputElement, boolean> &
React.AriaAttributes) => {
const fieldIdFromContext = useFieldId();
const inputId = htmlForId ?? fieldIdFromContext ?? undefined;
const classes = styles({
size,
tone,
Expand All @@ -60,10 +60,11 @@ export const Checkbox = ({
disabled={disabled}
invalid={invalid}
required={required}
ids={inputId ? { hiddenInput: inputId } : undefined}
ids={htmlForId ? { hiddenInput: htmlForId } : undefined}
Comment thread
alex-e-leon marked this conversation as resolved.
data-testid={testId}
ref={ref as React.Ref<HTMLLabelElement>}
className={cx(classes.root, className)}
value={htmlValue}
{...ariaProps}
>
<BaseCheckbox.Control className={classes.control}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { useState } from "react";

import { css } from "@hashintel/ds-helpers/css";

import { formInputSizes } from "../../util/form-shared";
import { CheckboxGroup } from "./checkbox-group";

import type { Story, StoryDefault } from "@ladle/react";

type Props = React.ComponentProps<typeof CheckboxGroup>;

const layouts: NonNullable<Props["layout"]>[] = [
"blockWithBorder",
"block",
"inline",
];

const fruitItems = [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
{ value: "cherry", label: "Cherry" },
];

const leftLabelItems = fruitItems.map((item) => ({
...item,
labelPlacement: "left" as const,
}));

const ControlledCheckboxGroup = ({
defaultValue = ["apple"],
...props
}: Omit<Props, "value" | "onChange" | "items"> & {
defaultValue?: string[];
items?: Props["items"];
}) => {
const [value, setValue] = useState(defaultValue);
return (
<CheckboxGroup
{...props}
items={props.items ?? fruitItems}
value={value}
onChange={setValue}
/>
);
};

export default {
title: "Components/CheckboxGroup",
parameters: {
layout: "centered",
},
argTypes: {
layout: { control: { type: "select", options: layouts } },
size: { control: { type: "select", options: formInputSizes } },
disabled: { control: { type: "boolean" } },
},
args: {
layout: "block",
size: "md",
disabled: false,
},
} satisfies StoryDefault<Props>;

const headingClass = css({
fontSize: "[12px]",
fontWeight: "medium",
color: "neutral.s90",
marginBottom: "[8px]",
});

const sectionClass = css({
display: "flex",
flexDirection: "column",
gap: "[24px]",
});

const subHeadingClass = css({
fontSize: "[11px]",
color: "neutral.s70",
marginBottom: "[6px]",
});

const layoutRowClass = css({
display: "flex",
gap: "[40px]",
alignItems: "flex-start",
flexWrap: "wrap",
});

export const Layouts: Story = () => (
<div className={sectionClass}>
{layouts.map((layout) => (
<div key={layout}>
<div className={headingClass}>layout={layout}</div>
<ControlledCheckboxGroup
layout={layout}
defaultValue={["apple", "cherry"]}
/>
</div>
))}
<div>
<div className={headingClass}>
layout=blockWithBorder, labelPlacement=left
</div>
<ControlledCheckboxGroup
layout="blockWithBorder"
items={leftLabelItems}
/>
</div>
<div>
<div className={headingClass}>layout=block, labelPlacement=left</div>
<ControlledCheckboxGroup layout="block" items={leftLabelItems} />
</div>
</div>
);

Layouts.parameters = {
controls: { disable: true },
};

export const Sizes: Story = () => (
<div className={sectionClass}>
{formInputSizes.map((size) => (
<div key={size}>
<div className={headingClass}>size={size}</div>
<div className={layoutRowClass}>
{layouts.map((layout) => (
<div key={layout}>
<div className={subHeadingClass}>{layout}</div>
<ControlledCheckboxGroup layout={layout} size={size} />
</div>
))}
</div>
</div>
))}
</div>
);

Sizes.parameters = {
controls: { disable: true },
};

export const Disabled: Story = () => (
<div className={sectionClass}>
<div>
<div className={headingClass}>whole group disabled</div>
<ControlledCheckboxGroup disabled defaultValue={["apple"]} />
</div>
<div>
<div className={headingClass}>single option disabled</div>
<ControlledCheckboxGroup
items={[
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana", disabled: true },
{ value: "cherry", label: "Cherry" },
]}
/>
</div>
</div>
);

Disabled.parameters = {
controls: { disable: true },
};

export const MaxSelectable: Story = () => (
<div className={sectionClass}>
<div>
<div className={headingClass}>maxSelectable=2</div>
<div className={subHeadingClass}>
Once two are selected, the remaining unchecked options are disabled
until one is unchecked.
</div>
<ControlledCheckboxGroup
maxSelectable={2}
defaultValue={[]}
items={[
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
{ value: "cherry", label: "Cherry" },
{ value: "date", label: "Date" },
]}
/>
</div>
</div>
);

MaxSelectable.parameters = {
controls: { disable: true },
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { CheckboxGroup as ArkCheckboxGroup } from "@ark-ui/react/checkbox";
import { useId } from "react";

import { cx } from "@hashintel/ds-helpers/css";

import {
getGroupFocusProps,
styles,
} from "../../util/radio-checkbox-group-shared";
import { Checkbox } from "../Checkbox/checkbox";

import type { SharedInputProps } from "../../util/form-shared";

type CheckboxGroupProps<ValueType extends string = string> = {
/** How the options are arranged (defaults to `block`) */
layout?: "block" | "inline" | "blockWithBorder";
/** The selectable options */
items: Array<
Omit<
React.ComponentProps<typeof Checkbox>,
"size" | "onChange" | "value" | "name" | "autoFocus" | "htmlForId"
> & { value: ValueType }
>;
maxSelectable?: number;
} & Omit<SharedInputProps<HTMLInputElement, NoInfer<ValueType>[]>, "inputRef"> &
React.AriaAttributes;

export const CheckboxGroup = <const ValueType extends string>({
layout = "block",
items,
disabled,
invalid,
className,
value,
onChange,
onFocus,
onBlur,
ref,
required,
testId,
size = "md",
autoFocus,
name,
maxSelectable,
...ariaProps
}: CheckboxGroupProps<ValueType>) => {
// A stable, shared `name` groups the underlying inputs for form submission.
const generatedName = useId();
const groupName = name ?? generatedName;

const selectedValues = new Set(value);
const isRequired = !!required || items.some((item) => item.required);
const isInvalid = !!invalid || items.some((item) => item.invalid);
const hasSelection = items.some((item) => selectedValues.has(item.value));

const atSelectionCap =
maxSelectable !== undefined && selectedValues.size >= maxSelectable;

return (
<ArkCheckboxGroup
name={groupName}
disabled={disabled}
// `aria-required` isn't part of the `group` role's ARIA contract, but is
// the conventional signal that a selection is required for the group.
aria-required={isRequired ? true : undefined}
invalid={isInvalid ? true : undefined}
data-testid={testId}
ref={ref as React.Ref<HTMLDivElement>}
className={cx(styles({ layout, size }), className)}
{...getGroupFocusProps({ onFocus, onBlur })}
{...ariaProps}
>
{items.map((item, index) => {
const {
value: optionValue,
disabled: itemDisabled,
required: itemRequired,
...itemProps
} = item;
const isSelected = selectedValues.has(optionValue);
// If the cap is reached, unchecked options are disabled so they can't
// be selected; already-selected options stay enabled so they can be
// unchecked to free up a slot.
const itemIsDisabled =
disabled === true ||
itemDisabled === true ||
(atSelectionCap && !isSelected);
// Native checkboxes have no "at least one of the group" constraint, so when
// the group is required we mark every option `required` while nothing is
// selected. That makes the group invalid (blocking submission) until one box
// is checked, at which point none of them need to be required anymore.
const itemIsRequired =
itemRequired === true || (required === true && !hasSelection);

return (
<Checkbox
key={optionValue}
{...itemProps}
size={size}
name={groupName}
disabled={itemIsDisabled}
required={itemIsRequired}
value={isSelected}
htmlValue={optionValue}
onChange={(checked) => {
if (checked) {
if (isSelected || atSelectionCap) {
return;
}
onChange([...value, optionValue]);
} else {
onChange(
value.filter((candidate) => candidate !== optionValue),
);
}
}}
autoFocus={autoFocus && index === 0}
/>
);
})}
</ArkCheckboxGroup>
);
};
Loading
Loading