diff --git a/.changeset/checkbox-examples-1.md b/.changeset/checkbox-examples-1.md new file mode 100644 index 0000000000..a1c74d49f8 --- /dev/null +++ b/.changeset/checkbox-examples-1.md @@ -0,0 +1,6 @@ +--- +"@ariakit/react-core": patch +"@ariakit/react": patch +--- + +The `Checkbox` component now accepts `string[]` as the `value` prop. This is to conform with the native input prop type. If a string array is passed, it will be stringified, just like in the native input element. ([#2456](https://github.com/ariakit/ariakit/pull/2456)) diff --git a/.changeset/checkbox-examples-2.md b/.changeset/checkbox-examples-2.md new file mode 100644 index 0000000000..ac8fcef8c7 --- /dev/null +++ b/.changeset/checkbox-examples-2.md @@ -0,0 +1,6 @@ +--- +"@ariakit/react-core": patch +"@ariakit/react": patch +--- + +Fixed the `clickOnEnter` prop on `Checkbox` not working when rendering the component as a native input element. ([#2456](https://github.com/ariakit/ariakit/pull/2456)) diff --git a/components/checkbox.md b/components/checkbox.md index 60393102bf..a7c60a1c29 100644 --- a/components/checkbox.md +++ b/components/checkbox.md @@ -6,13 +6,15 @@ Example -## Installation +## Examples -```sh -npm i @ariakit/react -``` +
-Learn more on the [Getting started](/guide/getting-started) guide. +- [](/examples/checkbox-as-button) +- [](/examples/checkbox-custom) +- [](/examples/checkbox-group) + +
## API @@ -23,3 +25,16 @@ Learn more on the [Getting started](/guide/getting-started) guide. <CheckboxCheck /> </Checkbox> + +## Related components + +
+ +- [](/components/button) +- [](/components/form) +- [](/components/menu) +- [](/components/radio) +- [](/components/select) +- [](/components/command) + +
diff --git a/components/group.md b/components/group.md new file mode 100644 index 0000000000..fb886e9516 --- /dev/null +++ b/components/group.md @@ -0,0 +1,22 @@ +# Group + +

+ Group related elements in a generic container that may have a label. This abstract component is based on the WAI-ARIA Group Role. +

+ +## API + +
+<Group>
+  <GroupLabel />
+</Group>
+
+ +## Related components + +
+ +- [](/components/form) +- [](/components/composite) + +
diff --git a/examples/checkbox-as-button/readme.md b/examples/checkbox-as-button/readme.md index ba815ecaf3..496f3f7b7e 100644 --- a/examples/checkbox-as-button/readme.md +++ b/examples/checkbox-as-button/readme.md @@ -4,8 +4,39 @@ Rendering a custom Checkbox as a button element in React, while keeping it accessible to screen reader and keyboard users.

+ + Example +## Components + +
+ +- [](/components/checkbox) +- [](/components/button) + +
+ +## Activating on Enter + +By default, native checkbox elements are activated on Space, but not on Enter. The Ariakit `Checkbox` component allows you to control this behavior using the [`clickOnEnter`](/apis/checkbox#clickonenter) and [`clickOnSpace`](/apis/checkbox#clickonspace) props. However, when rendering the `Checkbox` as any non-native input element, the `clickOnEnter` prop will be automatically set to `true`. + +## Reading the state + +In this example, we're reading the [`value`](/apis/checkbox-store#value) state from the checkbox store to render the button's text. This is done by using the selector form of the [`useState`](/apis/checkbox-store#usestate) hook: + +```jsx +const label = checkbox.useState((state) => + state.value ? "Checked" : "Unchecked" +); +``` + +Learn more about reading the state on the [Component stores](/guide/component-stores#reading-the-state) guide. + ## Styling When rendering the `Checkbox` component as a non-native `input` element, the `:checked` pseudo-class is not supported. To style the checked state, use the `aria-checked` attribute selector: @@ -13,8 +44,18 @@ When rendering the `Checkbox` component as a non-native `input` element, the `:c ```css .button[aria-checked="true"] { background-color: hsl(204 100% 40%); - color: hsl(204, 20%, 100%); + color: hsl(204 20% 100%); } ``` Learn more on the [Styling](/guide/styling) guide. + +## Related examples + +
+ +- [](/examples/checkbox-custom) +- [](/examples/checkbox-group) +- [](/examples/menu-item-checkbox) + +
diff --git a/examples/checkbox-as-button/site-icon.tsx b/examples/checkbox-as-button/site-icon.tsx new file mode 100644 index 0000000000..e0e2ae6c70 --- /dev/null +++ b/examples/checkbox-as-button/site-icon.tsx @@ -0,0 +1,20 @@ +export default function Icon() { + return ( + + +
+
+ + + +
+
+
+
+ ); +} diff --git a/examples/checkbox-as-button/style.css b/examples/checkbox-as-button/style.css index ee52eb9911..dd1b5f064c 100644 --- a/examples/checkbox-as-button/style.css +++ b/examples/checkbox-as-button/style.css @@ -5,15 +5,12 @@ text-blue-900 bg-blue-200/40 hover:bg-blue-200/60 - dark:text-blue-100 dark:bg-blue-600/25 dark:hover:bg-blue-600/40 - aria-checked:text-white aria-checked:bg-blue-600 aria-checked:hover:bg-blue-800 - dark:aria-checked:text-white dark:aria-checked:bg-blue-600 dark:aria-checked:hover:bg-blue-800 diff --git a/examples/checkbox-controlled/readme.md b/examples/checkbox-controlled/readme.md deleted file mode 100644 index 7f9e65bbab..0000000000 --- a/examples/checkbox-controlled/readme.md +++ /dev/null @@ -1,7 +0,0 @@ -# Controlled Checkbox - -

- Using controlled props, such as checked and onChange, with a native Checkbox component in React. -

- -Example diff --git a/examples/checkbox-custom/checkbox.tsx b/examples/checkbox-custom/checkbox.tsx new file mode 100644 index 0000000000..1ca49c7078 --- /dev/null +++ b/examples/checkbox-custom/checkbox.tsx @@ -0,0 +1,48 @@ +import { forwardRef, useState } from "react"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import * as Ariakit from "@ariakit/react"; + +interface CheckboxProps extends ComponentPropsWithoutRef<"input"> { + children?: ReactNode; +} + +export const Checkbox = forwardRef( + function Checkbox({ children, ...props }, ref) { + const [checked, setChecked] = useState(props.defaultChecked ?? false); + const [focusVisible, setFocusVisible] = useState(false); + return ( + + ); + } +); diff --git a/examples/checkbox-custom/index.tsx b/examples/checkbox-custom/index.tsx index 99c5cf851a..debbb62df4 100644 --- a/examples/checkbox-custom/index.tsx +++ b/examples/checkbox-custom/index.tsx @@ -1,24 +1,6 @@ -import { useState } from "react"; -import * as Ariakit from "@ariakit/react"; +import { Checkbox } from "./checkbox.jsx"; import "./style.css"; export default function Example() { - const checkbox = Ariakit.useCheckboxStore({ defaultValue: false }); - const [focusVisible, setFocusVisible] = useState(false); - const checked = checkbox.useState("value"); - return ( - - ); + return Ariakit; } diff --git a/examples/checkbox-custom/readme.md b/examples/checkbox-custom/readme.md index f86c6318c0..776fa34cbf 100644 --- a/examples/checkbox-custom/readme.md +++ b/examples/checkbox-custom/readme.md @@ -5,3 +5,22 @@

Example + +## Components + +
+ +- [](/components/checkbox) +- [](/components/visually-hidden) + +
+ +## Related examples + +
+ +- [](/examples/checkbox-as-button) +- [](/examples/checkbox-group) +- [](/examples/menu-item-checkbox) + +
diff --git a/examples/checkbox-custom/site-icon.tsx b/examples/checkbox-custom/site-icon.tsx new file mode 100644 index 0000000000..738e9afe64 --- /dev/null +++ b/examples/checkbox-custom/site-icon.tsx @@ -0,0 +1,23 @@ +export default function Icon() { + return ( + + +
+
+
+ + + +
+
+
+
+ + + ); +} diff --git a/examples/checkbox-custom/style.css b/examples/checkbox-custom/style.css index fca98e5d02..040e6fa542 100644 --- a/examples/checkbox-custom/style.css +++ b/examples/checkbox-custom/style.css @@ -1,19 +1,42 @@ -@import url("../checkbox/style.css"); - .checkbox { @apply - w-5 - h-5 - rounded flex - justify-center items-center - border - text-blue-900 - bg-blue-200/40 - border-blue-600 - dark:text-blue-100 - dark:bg-blue-600/25 - dark:border-blue-200/40 + gap-2 + p-4 + pr-6 + rounded-lg + select-none + border-2 + border-black/30 + dark:border-white/30 + data-[checked=true]:border-blue-600 + data-[checked=true]:dark:border-blue-600 + bg-white + dark:bg-gray-700 + shadow + dark:shadow-dark + data-[focus-visible]:outline + data-[focus-visible]:outline-4 + data-[focus-visible]:outline-blue-600/25 + ; +} + +.check { + @apply + block + rounded-full + p-0.5 + text-lg + bg-gray-150 + dark:bg-gray-850 + [border:inherit] + [stroke-dasharray:15] + [stroke-dashoffset:15] + data-[checked=true]:bg-blue-600 + data-[checked=true]:dark:bg-blue-600 + data-[checked=true]:text-white + data-[checked=true]:[stroke-dashoffset:0] + transition-[stroke-dashoffset] ; } diff --git a/examples/checkbox-custom/test.ts b/examples/checkbox-custom/test.ts index 67532d8dac..8098077dbc 100644 --- a/examples/checkbox-custom/test.ts +++ b/examples/checkbox-custom/test.ts @@ -1,18 +1,27 @@ import { click, getByRole, press } from "@ariakit/test"; test("check/uncheck on click", async () => { - expect(getByRole("checkbox")).not.toBeChecked(); - await click(getByRole("checkbox")); expect(getByRole("checkbox")).toBeChecked(); await click(getByRole("checkbox")); expect(getByRole("checkbox")).not.toBeChecked(); + await click(getByRole("checkbox")); + expect(getByRole("checkbox")).toBeChecked(); }); test("check/uncheck on space", async () => { - expect(getByRole("checkbox")).not.toBeChecked(); + expect(getByRole("checkbox")).toBeChecked(); await press.Tab(); await press.Space(); - expect(getByRole("checkbox")).toBeChecked(); + expect(getByRole("checkbox")).not.toBeChecked(); await press.Space(); + expect(getByRole("checkbox")).toBeChecked(); +}); + +test("check/uncheck on enter", async () => { + expect(getByRole("checkbox")).toBeChecked(); + await press.Tab(); + await press.Enter(); expect(getByRole("checkbox")).not.toBeChecked(); + await press.Enter(); + expect(getByRole("checkbox")).toBeChecked(); }); diff --git a/examples/checkbox-group/index.tsx b/examples/checkbox-group/index.tsx index 6db7f9c98d..736173c596 100644 --- a/examples/checkbox-group/index.tsx +++ b/examples/checkbox-group/index.tsx @@ -1,27 +1,20 @@ -import * as Ariakit from "@ariakit/react"; +import { Checkbox, Group, GroupLabel, useCheckboxStore } from "@ariakit/react"; import "./style.css"; export default function Example() { - const checkbox = Ariakit.useCheckboxStore({ defaultValue: [] }); + const checkbox = useCheckboxStore({ defaultValue: [] }); return ( - - Your favorite fruits + + Your favorite fruits - + ); } diff --git a/examples/checkbox-group/readme.md b/examples/checkbox-group/readme.md index bee8ed929f..c0b30ca58a 100644 --- a/examples/checkbox-group/readme.md +++ b/examples/checkbox-group/readme.md @@ -5,3 +5,22 @@

Example + +## Components + +
+ +- [](/components/checkbox) +- [](/components/group) + +
+ +## Related examples + +
+ +- [](/examples/checkbox-as-button) +- [](/examples/checkbox-custom) +- [](/examples/menu-item-checkbox) + +
diff --git a/examples/checkbox-group/site-icon.tsx b/examples/checkbox-group/site-icon.tsx new file mode 100644 index 0000000000..cdfdd03ba5 --- /dev/null +++ b/examples/checkbox-group/site-icon.tsx @@ -0,0 +1,31 @@ +function renderLine(checked = false) { + return ( +
+
+ + {checked && } + +
+
+
+ ); +} + +export default function Icon() { + return ( + + +
+ {renderLine(true)} + {renderLine()} + {renderLine(true)} +
+
+
+ ); +} diff --git a/examples/checkbox-store/readme.md b/examples/checkbox-store/readme.md deleted file mode 100644 index 132a1fec53..0000000000 --- a/examples/checkbox-store/readme.md +++ /dev/null @@ -1,7 +0,0 @@ -# useCheckboxStore - -

- Using the useCheckboxStore hook to control the state of the Checkbox component. -

- -Example diff --git a/examples/checkbox/site-icon.tsx b/examples/checkbox/site-icon.tsx index b5b8f607b3..b783744a52 100644 --- a/examples/checkbox/site-icon.tsx +++ b/examples/checkbox/site-icon.tsx @@ -8,7 +8,7 @@ export default function Icon() { fill="none" viewBox="0 0 24 24" strokeWidth={4} - className="h-8 w-8 stroke-white dark:stroke-white/90" + className="h-8 w-8 stroke-white" > diff --git a/examples/checkbox/style.css b/examples/checkbox/style.css index c653f2eeda..8a4ff5985a 100644 --- a/examples/checkbox/style.css +++ b/examples/checkbox/style.css @@ -9,6 +9,6 @@ .checkbox { @apply - focus-visible:ariakit-outline + data-[focus-visible]:outline-blue-600 ; } diff --git a/guide/200-styling/readme.md b/guide/200-styling/readme.md index 61eaa41380..b9ae062028 100644 --- a/guide/200-styling/readme.md +++ b/guide/200-styling/readme.md @@ -26,6 +26,17 @@ To safely use CSS selectors, utilize the `className` prop or provide your own `d +### `[aria-checked]` + +The `aria-checked` attribute is applied to the [Checkbox](/components/checkbox) component when the input is checked. It can be either `true` or `false`. + +```css +.checkbox[aria-checked="true"] { + background-color: hsl(204 100% 40%); + color: hsl(204 20% 100%); +} +``` + ### `[aria-disabled]` Not all HTML elements accept the `disabled` attribute. That's why the [Focusable](/components/focusable) component and all components that use it underneath will apply the `aria-disabled` attribute to the rendered element when the `disabled` prop is set to `true`. diff --git a/packages/ariakit-react-core/src/checkbox/checkbox.tsx b/packages/ariakit-react-core/src/checkbox/checkbox.tsx index 9339bbcd6e..06b268227f 100644 --- a/packages/ariakit-react-core/src/checkbox/checkbox.tsx +++ b/packages/ariakit-react-core/src/checkbox/checkbox.tsx @@ -1,4 +1,4 @@ -import type { ChangeEvent, MouseEvent } from "react"; +import type { ChangeEvent, InputHTMLAttributes, MouseEvent } from "react"; import { useEffect, useRef, useState } from "react"; import type { CommandOptions } from "../command/command.js"; import { useCommand } from "../command/command.js"; @@ -26,6 +26,13 @@ function isNativeCheckbox(tagName?: string, type?: string) { return tagName === "input" && (!type || type === "checkbox"); } +function getNonArrayValue(value: T) { + if (Array.isArray(value)) { + return value.toString(); + } + return value as Exclude; +} + /** * Returns props to create a `Checkbox` component. If the element is not a * native checkbox, the hook will return additional props to make sure it's @@ -48,8 +55,11 @@ export const useCheckbox = createHook( const storeChecked = useStoreState(store, (state) => { if (checkedProp !== undefined) return checkedProp; if (state.value === undefined) return; - if (valueProp) { - if (Array.isArray(state.value)) return state.value.includes(valueProp); + if (valueProp != null) { + if (Array.isArray(state.value)) { + const nonArrayValue = getNonArrayValue(valueProp); + return state.value.includes(nonArrayValue); + } return state.value === valueProp; } if (Array.isArray(state.value)) return false; @@ -94,12 +104,13 @@ export const useCheckbox = createHook( setChecked(elementChecked); store?.setValue((prevValue) => { - if (!valueProp) return elementChecked; + if (valueProp == null) return elementChecked; + const nonArrayValue = getNonArrayValue(valueProp); if (!Array.isArray(prevValue)) { - return prevValue === valueProp ? false : valueProp; + return prevValue === nonArrayValue ? false : nonArrayValue; } - if (elementChecked) return [...prevValue, valueProp]; - return prevValue.filter((v) => v !== valueProp); + if (elementChecked) return [...prevValue, nonArrayValue]; + return prevValue.filter((v) => v !== nonArrayValue); }); }); @@ -191,7 +202,7 @@ export interface CheckboxOptions * * ``` */ - value?: string | number; + value?: InputHTMLAttributes["value"]; /** * The default `checked` state of the checkbox. This prop is ignored if the * `checked` or the `store` props are provided. diff --git a/packages/ariakit-react-core/src/command/command.ts b/packages/ariakit-react-core/src/command/command.ts index 50c31c2fdc..cb65664195 100644 --- a/packages/ariakit-react-core/src/command/command.ts +++ b/packages/ariakit-react-core/src/command/command.ts @@ -17,14 +17,22 @@ function isNativeClick(event: KeyboardEvent) { if (!event.isTrusted) return false; // istanbul ignore next: can't test trusted events yet const element = event.currentTarget; - return ( - isButton(element) || - element.tagName === "SUMMARY" || - element.tagName === "INPUT" || - element.tagName === "TEXTAREA" || - element.tagName === "A" || - element.tagName === "SELECT" - ); + if (event.key === "Enter") { + return ( + isButton(element) || + element.tagName === "SUMMARY" || + element.tagName === "A" + ); + } + if (event.key === " ") { + return ( + isButton(element) || + element.tagName === "SUMMARY" || + element.tagName === "INPUT" || + element.tagName === "SELECT" + ); + } + return false; } /** diff --git a/website/app/(main)/[category]/[page]/page.tsx b/website/app/(main)/[category]/[page]/page.tsx index 43f3cef2e1..59d73b77df 100644 --- a/website/app/(main)/[category]/[page]/page.tsx +++ b/website/app/(main)/[category]/[page]/page.tsx @@ -91,6 +91,7 @@ const style = { underline-offset-[0.25em] font-medium dark:font-normal text-blue-700 dark:text-blue-400 + [&>code]:text-blue-900 [&>code]:dark:text-blue-300 `, h1: tw` text-2xl sm:text-4xl md:text-5xl font-extrabold dark:font-bold @@ -130,7 +131,7 @@ const style = { data-[type="warn"]:before:bg-amber-500 data-[type="warn"]:before:dark:bg-yellow-600 - data-[type="note"]:bg-blue-50 + data-[type="note"]:bg-blue-50/70 data-[type="note"]:dark:bg-blue-900/20 data-[type="note"]:before:bg-blue-600 `, @@ -153,8 +154,8 @@ const style = { paragraph: tw` dark:text-white/[85%] leading-7 tracking-[-0.016em] dark:tracking-[-0.008em] - [p&_code]:rounded [p&_code]:p-1 [p&_code]:text-[0.9375em] - [p&_code]:bg-black/[6.5%] dark:[p&_code]:bg-white/[6.5%] + [p&_code]:rounded [p&_code]:p-[0.25em] [p&_code]:text-[0.9375em] + [p&_code]:bg-black/[7.5%] dark:[p&_code]:bg-white/[7.5%] [p&_code]:font-monospace `, strong: tw` @@ -327,18 +328,9 @@ export default async function Page({ params }: PageProps) { const nextPageLink = nextPage && ( -
+
{getPageIcon(nextPage.category, nextPage.slug) || }
@@ -423,21 +415,23 @@ export default async function Page({ params }: PageProps) { ))}
{isExamples && ( - - View all{isComponentPage ? ` ${pageDetail?.title}` : ""}{" "} - examples - +
+ + View all + {isComponentPage ? ` ${pageDetail?.title}` : ""}{" "} + examples + +
)} {isComponents && ( - - View all components - +
+ + View all components + +
)} ); @@ -565,6 +559,15 @@ export default async function Page({ params }: PageProps) { if (!child.props.href) return paragraph; return <>{props.children}; }, + kbd: ({ node, ...props }) => ( + + ), strong: ({ node, ...props }) => (