Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Headless checkbox #68

Merged
merged 14 commits into from
Dec 10, 2024
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
4 changes: 3 additions & 1 deletion apps/docs/src/routes/checkbox/examples/checkbox.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
}

.checkbox-trigger {
background: #0a4d70;
width: 30px;
height: 30px;
border-radius: 8px;
position: relative;
background: gray;
}

.checkbox-trigger:focus-visible {
Expand All @@ -26,4 +26,6 @@
position: absolute;
inset: 0;
align-items: center;
background: #0a4d70;
border-radius: 8px;
}
28 changes: 28 additions & 0 deletions apps/docs/src/routes/checkbox/examples/description.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { component$, useStyles$ } from "@builder.io/qwik";
import { Checkbox } from "@kunai-consulting/qwik-components";
import { LuCheck } from "@qwikest/icons/lucide";

export default component$(() => {
useStyles$(styles);

const description = "By checking this box, you acknowledge that you have read, understood, and agree to our Terms of Service and Privacy Policy. This includes consent to process your personal data as described in our policies.";

return (
<Checkbox.Root isDescription>
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "8px" }}>
<Checkbox.Trigger class="checkbox-trigger">
<Checkbox.Indicator class="checkbox-indicator">
<LuCheck />
</Checkbox.Indicator>
</Checkbox.Trigger>
<Checkbox.Label>I accept the Terms and Conditions</Checkbox.Label>
</div>
<Checkbox.Description>
By checking this box, you acknowledge that you have read, understood, and agree to our Terms of Service and Privacy Policy. This includes consent to process your personal data as described in our policies.
</Checkbox.Description>
</Checkbox.Root>
);
});

// example styles
import styles from "./checkbox.css?inline";
54 changes: 54 additions & 0 deletions apps/docs/src/routes/checkbox/examples/form-mixed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { $, component$, type Signal, useSignal, useStyles$ } from "@builder.io/qwik";
import { Checkbox } from "@kunai-consulting/qwik-components";
import { LuCheck } from "@qwikest/icons/lucide";

export default component$(() => {
useStyles$(styles);

const formData = useSignal<Record<string, FormDataEntryValue>>();

const isChecked = useSignal<boolean | "mixed">("mixed");

return (
<form
preventdefault:submit
onSubmit$={(e) => {
const form = e.target as HTMLFormElement;
formData.value = Object.fromEntries(new FormData(form));
}}
style={{ display: "flex", flexDirection: "column", gap: "8px" }}
>
<TermsCheckbox isChecked={isChecked} />
<button
data-test-mixed
onClick$={() => {
isChecked.value = "mixed";
}}
type="button"
>
Make it mixed
</button>
</form>
);
});

export const TermsCheckbox = component$(
({ isChecked }: { isChecked: Signal<boolean | "mixed"> }) => {
return (
<Checkbox.Root bind:checked={isChecked}>
<Checkbox.HiddenNativeInput />
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Checkbox.Trigger class="checkbox-trigger">
<Checkbox.Indicator class="checkbox-indicator">
<LuCheck />
</Checkbox.Indicator>
</Checkbox.Trigger>
<Checkbox.Label>I accept the Terms and Conditions</Checkbox.Label>
</div>
</Checkbox.Root>
);
}
);

// example styles
import styles from "./checkbox.css?inline";
43 changes: 43 additions & 0 deletions apps/docs/src/routes/checkbox/examples/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { $, component$, useSignal, useStyles$, useStylesScoped$ } from "@builder.io/qwik";
import { Checkbox } from "@kunai-consulting/qwik-components";
import { LuCheck } from "@qwikest/icons/lucide";

export default component$(() => {
useStyles$(styles);

const formData = useSignal<Record<string, FormDataEntryValue>>();

return (
<form
preventdefault:submit
onSubmit$={(e) => {
const form = e.target as HTMLFormElement;
formData.value = Object.fromEntries(new FormData(form));
}}
style={{ display: "flex", flexDirection: "column", gap: "8px" }}
>
<TermsCheckbox />
<button type="submit">Submit</button>
{formData.value && <div>Submitted: {JSON.stringify(formData.value, null, 2)}</div>}
</form>
);
});

export const TermsCheckbox = component$(() => {
return (
<Checkbox.Root name="terms">
<Checkbox.HiddenNativeInput />
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Checkbox.Trigger class="checkbox-trigger">
<Checkbox.Indicator class="checkbox-indicator">
<LuCheck />
</Checkbox.Indicator>
</Checkbox.Trigger>
<Checkbox.Label>I accept the Terms and Conditions</Checkbox.Label>
</div>
</Checkbox.Root>
);
});

// example styles
import styles from "./checkbox.css?inline";
43 changes: 43 additions & 0 deletions apps/docs/src/routes/checkbox/examples/required.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { $, component$, useSignal, useStyles$, useStylesScoped$ } from "@builder.io/qwik";
import { Checkbox } from "@kunai-consulting/qwik-components";
import { LuCheck } from "@qwikest/icons/lucide";

export default component$(() => {
useStyles$(styles);

const formData = useSignal<Record<string, FormDataEntryValue>>();

return (
<form
preventdefault:submit
onSubmit$={(e) => {
const form = e.target as HTMLFormElement;
formData.value = Object.fromEntries(new FormData(form));
}}
style={{ display: "flex", flexDirection: "column", gap: "8px" }}
>
<TermsCheckbox />
<button type="submit">Submit</button>
{formData.value && <div>Submitted: {JSON.stringify(formData.value, null, 2)}</div>}
</form>
);
});

export const TermsCheckbox = component$(() => {
return (
<Checkbox.Root name="terms" required>
<Checkbox.HiddenNativeInput />
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Checkbox.Trigger class="checkbox-trigger">
<Checkbox.Indicator class="checkbox-indicator">
<LuCheck />
</Checkbox.Indicator>
</Checkbox.Trigger>
<Checkbox.Label>I accept the Terms and Conditions</Checkbox.Label>
</div>
</Checkbox.Root>
);
});

// example styles
import styles from "./checkbox.css?inline";
70 changes: 70 additions & 0 deletions apps/docs/src/routes/checkbox/examples/validation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
component$,
type Signal,
useComputed$,
useSignal,
useStyles$
} from "@builder.io/qwik";
import { Checkbox } from "@kunai-consulting/qwik-components";
import { LuCheck } from "@qwikest/icons/lucide";

export default component$(() => {
useStyles$(styles);

const formData = useSignal<Record<string, FormDataEntryValue>>();
const isChecked = useSignal(false);
const isSubmitAttempt = useSignal(false);
const isError = useComputed$(() => !isChecked.value && isSubmitAttempt.value);

return (
<form
preventdefault:submit
noValidate
onSubmit$={(e) => {
const form = e.target as HTMLFormElement;
if (!isChecked.value) {
isSubmitAttempt.value = true;
return;
}

formData.value = Object.fromEntries(new FormData(form));
}}
style={{ display: "flex", flexDirection: "column", gap: "8px" }}
>
<TermsCheckbox isChecked={isChecked} isError={isError} />
<button type="submit">Submit</button>
{formData.value && <div>Submitted: {JSON.stringify(formData.value, null, 2)}</div>}
</form>
);
});

type TermsCheckboxProps = {
isChecked: Signal<boolean>;
isError: Signal<boolean>;
};

export const TermsCheckbox = component$(({ isChecked, isError }: TermsCheckboxProps) => {
return (
<Checkbox.Root name="terms" required bind:checked={isChecked}>
<Checkbox.HiddenNativeInput />
<div
style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "8px" }}
>
<Checkbox.Trigger class="checkbox-trigger">
<Checkbox.Indicator class="checkbox-indicator">
<LuCheck />
</Checkbox.Indicator>
</Checkbox.Trigger>
<Checkbox.Label>I accept the Terms and Conditions</Checkbox.Label>
</div>
{isError.value && (
<Checkbox.ErrorMessage style={{ color: "red" }}>
Please accept the terms and conditions
</Checkbox.ErrorMessage>
)}
</Checkbox.Root>
);
});

// example styles
import styles from "./checkbox.css?inline";
43 changes: 43 additions & 0 deletions apps/docs/src/routes/checkbox/examples/value.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { $, component$, useSignal, useStyles$, useStylesScoped$ } from "@builder.io/qwik";
import { Checkbox } from "@kunai-consulting/qwik-components";
import { LuCheck } from "@qwikest/icons/lucide";

export default component$(() => {
useStyles$(styles);

const formData = useSignal<Record<string, FormDataEntryValue>>();

return (
<form
preventdefault:submit
onSubmit$={(e) => {
const form = e.target as HTMLFormElement;
formData.value = Object.fromEntries(new FormData(form));
}}
style={{ display: "flex", flexDirection: "column", gap: "8px" }}
>
<TermsCheckbox />
<button type="submit">Submit</button>
{formData.value && <div>Submitted: {JSON.stringify(formData.value, null, 2)}</div>}
</form>
);
});

export const TermsCheckbox = component$(() => {
return (
<Checkbox.Root name="terms" value="checked">
<Checkbox.HiddenNativeInput />
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Checkbox.Trigger class="checkbox-trigger">
<Checkbox.Indicator class="checkbox-indicator">
<LuCheck />
</Checkbox.Indicator>
</Checkbox.Trigger>
<Checkbox.Label>I accept the Terms and Conditions</Checkbox.Label>
</div>
</Checkbox.Root>
);
});

// example styles
import styles from "./checkbox.css?inline";
47 changes: 46 additions & 1 deletion apps/docs/src/routes/checkbox/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ To associate a label with a checkbox, use the `Checkbox.Label` component.

<Showcase name="label" />

## Adding a description

To add a description to a checkbox, add the `isDescription` prop in the `Checkbox.Root` component.

Then use the `Checkbox.Description` component to decide where to render the description.

<Showcase name="description" />

> **Note:** Due to HTML streaming limitations, the `isDescription` prop must be passed to the root component to maintain consistent accessibility across different environments (especially server rendered content). The component will display a warning if no description is provided when this component is rendered.

## Initially check a checkbox

To set a checkbox to its initial checked state, use the `checked` prop.
Expand Down Expand Up @@ -49,14 +59,49 @@ To disable a checkbox, use the `disabled` prop.

Checkboxes can also be in a third mixed or indeterminate state. This is often considered a "partially checked" state.

To set a checkbox to an initial mixed state, use the `mixed` prop.
To set a mixed state, pass `"mixed"` to the `bind:checked` prop:

<Showcase name="mixed-initial" />

The mixed state can also be set reactively

<Showcase name="mixed-reactive" />

> When a checkbox is in a mixed state and the user clicks it:
> 1. The first click changes the state from "mixed" to "checked"
> 2. The next click changes the state from "checked" to "unchecked"
>
> This follows the standard accessibility pattern where mixed → checked → unchecked → checked → unchecked, and so on.

## Inside a form

To create a form with a checkbox, use the `Checkbox.HiddenNativeInput` component.

<Showcase name="form" />

Then create a name for the checkbox in the `Checkbox.Root` component.

> This will be used to associate the checkbox with the form.

## Making a checkbox required

To make a checkbox required, pass `required` to the `Checkbox.Root` component.

<Showcase name="required" />

## Giving a checkbox a value

By default, the value of a checkbox is `on` when checked. To give a checkbox a distinct value, pass a string to the `value` prop.

<Showcase name="value" />

## Checkbox validation

To validate a checkbox, use the `Checkbox.ErrorMessage` component. Whenever this component is rendered, the checkbox will display a validation error message.

> Screen readers will announce the error message when the component is rendered, along with an indication that the checkbox is invalid.

<Showcase name="validation" />



9 changes: 7 additions & 2 deletions libs/components/src/checkbox/checkbox-context.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { createContextId, type Signal } from "@builder.io/qwik";
import { createContextId, JSXNode, type Signal } from "@builder.io/qwik";

export const checkboxContextId = createContextId<CheckboxContext>("qds-checkbox-context");

export type CheckboxContext = {
isCheckedSig: Signal<boolean | "mixed">;
isDisabledSig: Signal<boolean | undefined>;
isMixedSig: Signal<boolean | undefined>;
isErrorSig: Signal<boolean | undefined>;
localId: string;
isDescription: boolean | undefined;
name: string | undefined;
required: boolean | undefined;
value: string | undefined;
triggerRef: Signal<HTMLButtonElement | undefined>;
};
Loading