From 1d57eab6afbe89da53134124d4889570764ebdbd Mon Sep 17 00:00:00 2001 From: jack shelton Date: Wed, 20 Nov 2024 10:03:35 -0600 Subject: [PATCH 01/13] better mixed functionality --- .../src/checkbox/checkbox-context.tsx | 1 - .../src/checkbox/checkbox-indicator.tsx | 2 +- .../components/src/checkbox/checkbox-root.tsx | 4 +-- .../src/checkbox/checkbox-trigger.tsx | 6 ++++- libs/components/src/checkbox/checkbox.test.ts | 27 +++++++++++++++++++ 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/libs/components/src/checkbox/checkbox-context.tsx b/libs/components/src/checkbox/checkbox-context.tsx index 05e1020..cb05611 100644 --- a/libs/components/src/checkbox/checkbox-context.tsx +++ b/libs/components/src/checkbox/checkbox-context.tsx @@ -5,6 +5,5 @@ export const checkboxContextId = createContextId("qds-checkbox- export type CheckboxContext = { isCheckedSig: Signal; isDisabledSig: Signal; - isMixedSig: Signal; localId: string; }; diff --git a/libs/components/src/checkbox/checkbox-indicator.tsx b/libs/components/src/checkbox/checkbox-indicator.tsx index 74291e6..4d16d2a 100644 --- a/libs/components/src/checkbox/checkbox-indicator.tsx +++ b/libs/components/src/checkbox/checkbox-indicator.tsx @@ -21,7 +21,7 @@ export const CheckboxIndicator = component$((props) => { {...props} data-hidden={!context.isCheckedSig.value} data-checked={context.isCheckedSig.value ? "" : undefined} - data-mixed={context.isMixedSig.value ? "" : undefined} + data-mixed={context.isCheckedSig.value === "mixed" ? "" : undefined} data-qds-indicator aria-hidden="true" > diff --git a/libs/components/src/checkbox/checkbox-root.tsx b/libs/components/src/checkbox/checkbox-root.tsx index 4f07e88..00e1125 100644 --- a/libs/components/src/checkbox/checkbox-root.tsx +++ b/libs/components/src/checkbox/checkbox-root.tsx @@ -32,13 +32,11 @@ export const CheckboxRoot = component$((props: CheckboxRootProps) => { const isCheckedSig = useBoundSignal(givenCheckedSig, checked); const isInitialLoadSig = useSignal(true); const isDisabledSig = useComputed$(() => props.disabled); - const isMixedSig = useComputed$(() => isCheckedSig.value === "mixed"); const localId = useId(); const context: CheckboxContext = { isCheckedSig, isDisabledSig, - isMixedSig, localId }; @@ -65,7 +63,7 @@ export const CheckboxRoot = component$((props: CheckboxRootProps) => { data-disabled={context.isDisabledSig.value ? "" : undefined} aria-disabled={context.isDisabledSig.value ? "true" : "false"} data-checked={context.isCheckedSig.value ? "" : undefined} - data-mixed={context.isMixedSig.value ? "" : undefined} + data-mixed={context.isCheckedSig.value === "mixed" ? "" : undefined} > diff --git a/libs/components/src/checkbox/checkbox-trigger.tsx b/libs/components/src/checkbox/checkbox-trigger.tsx index ad9f75f..2ef874f 100644 --- a/libs/components/src/checkbox/checkbox-trigger.tsx +++ b/libs/components/src/checkbox/checkbox-trigger.tsx @@ -8,7 +8,11 @@ export const CheckboxTrigger = component$((props: CheckboxControlProps) => { const triggerId = `${context.localId}-trigger`; const handleClick$ = $(() => { - context.isCheckedSig.value = !context.isCheckedSig.value; + if (context.isCheckedSig.value === "mixed") { + context.isCheckedSig.value = true; + } else { + context.isCheckedSig.value = !context.isCheckedSig.value; + } }); const handleKeyDownSync$ = sync$((e: KeyboardEvent) => { diff --git a/libs/components/src/checkbox/checkbox.test.ts b/libs/components/src/checkbox/checkbox.test.ts index 9dec38d..7af69a9 100644 --- a/libs/components/src/checkbox/checkbox.test.ts +++ b/libs/components/src/checkbox/checkbox.test.ts @@ -221,4 +221,31 @@ test.describe("a11y", () => { // initial setup await expect(d.getTrigger()).toHaveAttribute("aria-checked", "mixed"); }); + + test(`GIVEN a checkbox that is initially mixed + WHEN the checkbox is clicked + THEN it should become checked`, async ({ page }) => { + const d = await setup(page, "mixed-initial"); + + await expect(d.getTrigger()).toHaveAttribute("aria-checked", "mixed"); + + await d.getTrigger().click(); + await expect(d.getTrigger()).toHaveAttribute("aria-checked", "true"); + await expect(d.getIndicator()).toBeVisible(); + }); + + test(`GIVEN a checkbox that was mixed and is now checked + WHEN the checkbox is clicked again + THEN it should become unchecked`, async ({ page }) => { + const d = await setup(page, "mixed-initial"); + + // Get to checked state first + await d.getTrigger().click(); + await expect(d.getTrigger()).toHaveAttribute("aria-checked", "true"); + + // Now click again + await d.getTrigger().click(); + await expect(d.getTrigger()).toHaveAttribute("aria-checked", "false"); + await expect(d.getIndicator()).toBeHidden(); + }); }); From 21c18ceceed8d29f7aea4e4ff88aedbe2a340cb8 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Wed, 20 Nov 2024 10:13:05 -0600 Subject: [PATCH 02/13] mixed state finished --- apps/docs/src/routes/checkbox/index.mdx | 8 ++++++-- libs/components/src/checkbox/checkbox.test.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/routes/checkbox/index.mdx b/apps/docs/src/routes/checkbox/index.mdx index 15fc481..18b0395 100644 --- a/apps/docs/src/routes/checkbox/index.mdx +++ b/apps/docs/src/routes/checkbox/index.mdx @@ -49,7 +49,7 @@ 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: @@ -57,6 +57,10 @@ The mixed state can also be set reactively - +> 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. diff --git a/libs/components/src/checkbox/checkbox.test.ts b/libs/components/src/checkbox/checkbox.test.ts index 7af69a9..7515450 100644 --- a/libs/components/src/checkbox/checkbox.test.ts +++ b/libs/components/src/checkbox/checkbox.test.ts @@ -152,6 +152,19 @@ test.describe("state", () => { await toggleDisabledEl.click(); await expect(d.getTrigger()).toBeEnabled(); }); + + test(`GIVEN a checkbox with bind:checked + WHEN programmatically setting the state to mixed + THEN the checkbox should reflect the mixed state`, async ({ page }) => { + const d = await setup(page, "mixed-reactive"); + + await expect(d.getTrigger()).toHaveAttribute("aria-checked", "false"); + + const mixedButton = page.locator("button").last(); + await mixedButton.click(); + + await expect(d.getTrigger()).toHaveAttribute("aria-checked", "mixed"); + }); }); test.describe("a11y", () => { From 8b34732aa1e37d42d7bda191d41b2557380b541a Mon Sep 17 00:00:00 2001 From: jack shelton Date: Wed, 20 Nov 2024 15:00:41 -0600 Subject: [PATCH 03/13] initial desc --- .../routes/checkbox/examples/description.tsx | 26 +++++++++++++++++++ apps/docs/src/routes/checkbox/index.mdx | 6 +++++ .../src/checkbox/checkbox-context.tsx | 1 + .../src/checkbox/checkbox-description.tsx | 23 ++++++++++++++++ .../components/src/checkbox/checkbox-root.tsx | 2 ++ .../src/checkbox/checkbox-trigger.tsx | 2 ++ libs/components/src/checkbox/index.ts | 1 + 7 files changed, 61 insertions(+) create mode 100644 apps/docs/src/routes/checkbox/examples/description.tsx create mode 100644 libs/components/src/checkbox/checkbox-description.tsx diff --git a/apps/docs/src/routes/checkbox/examples/description.tsx b/apps/docs/src/routes/checkbox/examples/description.tsx new file mode 100644 index 0000000..3cfc52c --- /dev/null +++ b/apps/docs/src/routes/checkbox/examples/description.tsx @@ -0,0 +1,26 @@ +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); + + return ( + + + + + + + I accept the Terms and Conditions + + 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. + + + ); +}); + +// example styles +import styles from "./checkbox.css?inline"; diff --git a/apps/docs/src/routes/checkbox/index.mdx b/apps/docs/src/routes/checkbox/index.mdx index 18b0395..c7eb9cb 100644 --- a/apps/docs/src/routes/checkbox/index.mdx +++ b/apps/docs/src/routes/checkbox/index.mdx @@ -15,6 +15,12 @@ To associate a label with a checkbox, use the `Checkbox.Label` component. +## Adding a description + +To add a description to a checkbox, use the `Checkbox.Description` component. + + + ## Initially check a checkbox To set a checkbox to its initial checked state, use the `checked` prop. diff --git a/libs/components/src/checkbox/checkbox-context.tsx b/libs/components/src/checkbox/checkbox-context.tsx index cb05611..6e754a4 100644 --- a/libs/components/src/checkbox/checkbox-context.tsx +++ b/libs/components/src/checkbox/checkbox-context.tsx @@ -5,5 +5,6 @@ export const checkboxContextId = createContextId("qds-checkbox- export type CheckboxContext = { isCheckedSig: Signal; isDisabledSig: Signal; + isDescriptionSig: Signal; localId: string; }; diff --git a/libs/components/src/checkbox/checkbox-description.tsx b/libs/components/src/checkbox/checkbox-description.tsx new file mode 100644 index 0000000..51a2254 --- /dev/null +++ b/libs/components/src/checkbox/checkbox-description.tsx @@ -0,0 +1,23 @@ +import { component$, type PropsOf, Slot, useContext, useTask$ } from "@builder.io/qwik"; +import { checkboxContextId } from "./checkbox-context"; + +type CheckboxDescriptionProps = PropsOf<"div">; + +export const CheckboxDescription = component$((props: CheckboxDescriptionProps) => { + const context = useContext(checkboxContextId); + const descriptionId = `${context.localId}-description`; + + useTask$(({ cleanup }) => { + context.isDescriptionSig.value = true; + + cleanup(() => { + context.isDescriptionSig.value = false; + }); + }); + + return ( +
+ +
+ ); +}); diff --git a/libs/components/src/checkbox/checkbox-root.tsx b/libs/components/src/checkbox/checkbox-root.tsx index 00e1125..1ac1343 100644 --- a/libs/components/src/checkbox/checkbox-root.tsx +++ b/libs/components/src/checkbox/checkbox-root.tsx @@ -32,11 +32,13 @@ export const CheckboxRoot = component$((props: CheckboxRootProps) => { const isCheckedSig = useBoundSignal(givenCheckedSig, checked); const isInitialLoadSig = useSignal(true); const isDisabledSig = useComputed$(() => props.disabled); + const isDescriptionSig = useSignal(false); const localId = useId(); const context: CheckboxContext = { isCheckedSig, isDisabledSig, + isDescriptionSig, localId }; diff --git a/libs/components/src/checkbox/checkbox-trigger.tsx b/libs/components/src/checkbox/checkbox-trigger.tsx index 2ef874f..37270bc 100644 --- a/libs/components/src/checkbox/checkbox-trigger.tsx +++ b/libs/components/src/checkbox/checkbox-trigger.tsx @@ -6,6 +6,7 @@ type CheckboxControlProps = PropsOf<"button">; export const CheckboxTrigger = component$((props: CheckboxControlProps) => { const context = useContext(checkboxContextId); const triggerId = `${context.localId}-trigger`; + const descriptionId = `${context.localId}-description`; const handleClick$ = $(() => { if (context.isCheckedSig.value === "mixed") { @@ -27,6 +28,7 @@ export const CheckboxTrigger = component$((props: CheckboxControlProps) => { type="button" role="checkbox" aria-checked={`${context.isCheckedSig.value}`} + aria-describedby={context.isDescriptionSig.value ? descriptionId : undefined} disabled={context.isDisabledSig.value} data-disabled={context.isDisabledSig.value ? "" : undefined} onKeyDown$={[handleKeyDownSync$, props.onKeyDown$]} diff --git a/libs/components/src/checkbox/index.ts b/libs/components/src/checkbox/index.ts index fceda09..f426954 100644 --- a/libs/components/src/checkbox/index.ts +++ b/libs/components/src/checkbox/index.ts @@ -2,3 +2,4 @@ export { CheckboxRoot as Root } from "./checkbox-root"; export { CheckboxIndicator as Indicator } from "./checkbox-indicator"; export { CheckboxTrigger as Trigger } from "./checkbox-trigger"; export { CheckboxLabel as Label } from "./checkbox-label"; +export { CheckboxDescription as Description } from "./checkbox-description"; From 7d028f9e401c03cb18abae248717c84a13bf90b1 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Thu, 21 Nov 2024 10:42:06 -0600 Subject: [PATCH 04/13] description --- .../routes/checkbox/examples/description.tsx | 24 +++++++++---------- apps/docs/src/routes/checkbox/index.mdx | 6 ++++- .../src/checkbox/checkbox-context.tsx | 4 ++-- .../src/checkbox/checkbox-description.tsx | 18 +++++++------- .../components/src/checkbox/checkbox-root.tsx | 12 ++++++---- .../src/checkbox/checkbox-trigger.tsx | 2 +- .../src/checklist/checklist-item.tsx | 2 +- .../src/checklist/checklist-selectall.tsx | 1 - 8 files changed, 36 insertions(+), 33 deletions(-) diff --git a/apps/docs/src/routes/checkbox/examples/description.tsx b/apps/docs/src/routes/checkbox/examples/description.tsx index 3cfc52c..b179b28 100644 --- a/apps/docs/src/routes/checkbox/examples/description.tsx +++ b/apps/docs/src/routes/checkbox/examples/description.tsx @@ -5,19 +5,19 @@ 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 ( - - - - - - - I accept the Terms and Conditions - - 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. - + +
+ + + + + + I accept the Terms and Conditions +
+
); }); diff --git a/apps/docs/src/routes/checkbox/index.mdx b/apps/docs/src/routes/checkbox/index.mdx index c7eb9cb..826f568 100644 --- a/apps/docs/src/routes/checkbox/index.mdx +++ b/apps/docs/src/routes/checkbox/index.mdx @@ -17,10 +17,14 @@ To associate a label with a checkbox, use the `Checkbox.Label` component. ## Adding a description -To add a description to a checkbox, use the `Checkbox.Description` component. +To add a description to a checkbox, pass a string or JSX node to the `description` prop in the `Checkbox.Root` component. + +Then use the `Checkbox.Description` component to decide where to render the description. +> **Note:** Due to HTML streaming limitations, the `description` 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. diff --git a/libs/components/src/checkbox/checkbox-context.tsx b/libs/components/src/checkbox/checkbox-context.tsx index 6e754a4..0b5da9d 100644 --- a/libs/components/src/checkbox/checkbox-context.tsx +++ b/libs/components/src/checkbox/checkbox-context.tsx @@ -1,10 +1,10 @@ -import { createContextId, type Signal } from "@builder.io/qwik"; +import { createContextId, JSXNode, type Signal } from "@builder.io/qwik"; export const checkboxContextId = createContextId("qds-checkbox-context"); export type CheckboxContext = { isCheckedSig: Signal; isDisabledSig: Signal; - isDescriptionSig: Signal; localId: string; + description: string | JSXNode | undefined; }; diff --git a/libs/components/src/checkbox/checkbox-description.tsx b/libs/components/src/checkbox/checkbox-description.tsx index 51a2254..7eeea32 100644 --- a/libs/components/src/checkbox/checkbox-description.tsx +++ b/libs/components/src/checkbox/checkbox-description.tsx @@ -1,4 +1,4 @@ -import { component$, type PropsOf, Slot, useContext, useTask$ } from "@builder.io/qwik"; +import { component$, type PropsOf, Slot, sync$, useContext, useOnWindow, useTask$ } from "@builder.io/qwik"; import { checkboxContextId } from "./checkbox-context"; type CheckboxDescriptionProps = PropsOf<"div">; @@ -7,17 +7,15 @@ export const CheckboxDescription = component$((props: CheckboxDescriptionProps) const context = useContext(checkboxContextId); const descriptionId = `${context.localId}-description`; - useTask$(({ cleanup }) => { - context.isDescriptionSig.value = true; - - cleanup(() => { - context.isDescriptionSig.value = false; - }); - }); + useTask$(() => { + if (!context.description) { + console.warn('Qwik Design System Warning: No description prop provided to the Checkbox Root component.'); + } + }) return ( -
- +
+ {context.description}
); }); diff --git a/libs/components/src/checkbox/checkbox-root.tsx b/libs/components/src/checkbox/checkbox-root.tsx index 1ac1343..a9ef1c9 100644 --- a/libs/components/src/checkbox/checkbox-root.tsx +++ b/libs/components/src/checkbox/checkbox-root.tsx @@ -8,7 +8,8 @@ import { useTask$, useSignal, type QRL, - useComputed$ + useComputed$, + JSXNode } from "@builder.io/qwik"; import { useBoundSignal } from "../../utils/bound-signal"; import { type CheckboxContext, checkboxContextId } from "./checkbox-context"; @@ -18,6 +19,7 @@ export type CheckboxRootProps = { checked?: T; onChange$?: QRL<(checked: T) => void>; disabled?: boolean; + description?: string | JSXNode; } & PropsOf<"div">; export const CheckboxRoot = component$((props: CheckboxRootProps) => { @@ -26,20 +28,20 @@ export const CheckboxRoot = component$((props: CheckboxRootProps) => { checked, onClick$, onChange$, + description, ...rest } = props; - const isCheckedSig = useBoundSignal(givenCheckedSig, checked); + const isCheckedSig = useBoundSignal(givenCheckedSig, checked ?? false); const isInitialLoadSig = useSignal(true); const isDisabledSig = useComputed$(() => props.disabled); - const isDescriptionSig = useSignal(false); const localId = useId(); const context: CheckboxContext = { isCheckedSig, isDisabledSig, - isDescriptionSig, - localId + localId, + description }; useContextProvider(checkboxContextId, context); diff --git a/libs/components/src/checkbox/checkbox-trigger.tsx b/libs/components/src/checkbox/checkbox-trigger.tsx index 37270bc..df327ad 100644 --- a/libs/components/src/checkbox/checkbox-trigger.tsx +++ b/libs/components/src/checkbox/checkbox-trigger.tsx @@ -28,7 +28,7 @@ export const CheckboxTrigger = component$((props: CheckboxControlProps) => { type="button" role="checkbox" aria-checked={`${context.isCheckedSig.value}`} - aria-describedby={context.isDescriptionSig.value ? descriptionId : undefined} + aria-describedby={context.description ? descriptionId : undefined} disabled={context.isDisabledSig.value} data-disabled={context.isDisabledSig.value ? "" : undefined} onKeyDown$={[handleKeyDownSync$, props.onKeyDown$]} diff --git a/libs/components/src/checklist/checklist-item.tsx b/libs/components/src/checklist/checklist-item.tsx index 36d6f48..eaf5ece 100644 --- a/libs/components/src/checklist/checklist-item.tsx +++ b/libs/components/src/checklist/checklist-item.tsx @@ -66,7 +66,7 @@ export const ChecklistItem = component$((props: ChecklistItemProps) => { }); return ( - + ); diff --git a/libs/components/src/checklist/checklist-selectall.tsx b/libs/components/src/checklist/checklist-selectall.tsx index e022d6d..0578605 100644 --- a/libs/components/src/checklist/checklist-selectall.tsx +++ b/libs/components/src/checklist/checklist-selectall.tsx @@ -34,7 +34,6 @@ export const ChecklistSelectAll = component$((props: PropsOf<'div'>) => { */ return ( Date: Thu, 21 Nov 2024 23:10:08 -0600 Subject: [PATCH 05/13] better description --- apps/docs/src/routes/checkbox/examples/description.tsx | 6 ++++-- apps/docs/src/routes/checkbox/index.mdx | 4 ++-- libs/components/src/checkbox/checkbox-context.tsx | 2 +- libs/components/src/checkbox/checkbox-description.tsx | 4 ++-- libs/components/src/checkbox/checkbox-root.tsx | 6 +++--- libs/components/src/checkbox/checkbox-trigger.tsx | 2 +- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/docs/src/routes/checkbox/examples/description.tsx b/apps/docs/src/routes/checkbox/examples/description.tsx index b179b28..20c2838 100644 --- a/apps/docs/src/routes/checkbox/examples/description.tsx +++ b/apps/docs/src/routes/checkbox/examples/description.tsx @@ -8,7 +8,7 @@ export default component$(() => { 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 ( - +
@@ -17,7 +17,9 @@ export default component$(() => { I accept the Terms and Conditions
- + + 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. +
); }); diff --git a/apps/docs/src/routes/checkbox/index.mdx b/apps/docs/src/routes/checkbox/index.mdx index 826f568..4ab657b 100644 --- a/apps/docs/src/routes/checkbox/index.mdx +++ b/apps/docs/src/routes/checkbox/index.mdx @@ -17,13 +17,13 @@ To associate a label with a checkbox, use the `Checkbox.Label` component. ## Adding a description -To add a description to a checkbox, pass a string or JSX node to the `description` prop in the `Checkbox.Root` component. +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. -> **Note:** Due to HTML streaming limitations, the `description` 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. +> **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 diff --git a/libs/components/src/checkbox/checkbox-context.tsx b/libs/components/src/checkbox/checkbox-context.tsx index 0b5da9d..c798d07 100644 --- a/libs/components/src/checkbox/checkbox-context.tsx +++ b/libs/components/src/checkbox/checkbox-context.tsx @@ -6,5 +6,5 @@ export type CheckboxContext = { isCheckedSig: Signal; isDisabledSig: Signal; localId: string; - description: string | JSXNode | undefined; + isDescription: boolean | undefined; }; diff --git a/libs/components/src/checkbox/checkbox-description.tsx b/libs/components/src/checkbox/checkbox-description.tsx index 7eeea32..aad76be 100644 --- a/libs/components/src/checkbox/checkbox-description.tsx +++ b/libs/components/src/checkbox/checkbox-description.tsx @@ -8,14 +8,14 @@ export const CheckboxDescription = component$((props: CheckboxDescriptionProps) const descriptionId = `${context.localId}-description`; useTask$(() => { - if (!context.description) { + if (!context.isDescription) { console.warn('Qwik Design System Warning: No description prop provided to the Checkbox Root component.'); } }) return (
- {context.description} +
); }); diff --git a/libs/components/src/checkbox/checkbox-root.tsx b/libs/components/src/checkbox/checkbox-root.tsx index a9ef1c9..eb6326f 100644 --- a/libs/components/src/checkbox/checkbox-root.tsx +++ b/libs/components/src/checkbox/checkbox-root.tsx @@ -19,7 +19,7 @@ export type CheckboxRootProps = { checked?: T; onChange$?: QRL<(checked: T) => void>; disabled?: boolean; - description?: string | JSXNode; + isDescription?: boolean; } & PropsOf<"div">; export const CheckboxRoot = component$((props: CheckboxRootProps) => { @@ -28,7 +28,7 @@ export const CheckboxRoot = component$((props: CheckboxRootProps) => { checked, onClick$, onChange$, - description, + isDescription, ...rest } = props; @@ -41,7 +41,7 @@ export const CheckboxRoot = component$((props: CheckboxRootProps) => { isCheckedSig, isDisabledSig, localId, - description + isDescription }; useContextProvider(checkboxContextId, context); diff --git a/libs/components/src/checkbox/checkbox-trigger.tsx b/libs/components/src/checkbox/checkbox-trigger.tsx index df327ad..c2ebb57 100644 --- a/libs/components/src/checkbox/checkbox-trigger.tsx +++ b/libs/components/src/checkbox/checkbox-trigger.tsx @@ -28,7 +28,7 @@ export const CheckboxTrigger = component$((props: CheckboxControlProps) => { type="button" role="checkbox" aria-checked={`${context.isCheckedSig.value}`} - aria-describedby={context.description ? descriptionId : undefined} + aria-describedby={context.isDescription ? descriptionId : undefined} disabled={context.isDisabledSig.value} data-disabled={context.isDisabledSig.value ? "" : undefined} onKeyDown$={[handleKeyDownSync$, props.onKeyDown$]} From c24b490bed8f62b01b2e1ff130449895369b3ce3 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Sat, 23 Nov 2024 12:54:28 -0600 Subject: [PATCH 06/13] feat: initial support for native checkbox submissions --- .../src/routes/checkbox/examples/form.tsx | 43 +++++++++++++++++++ apps/docs/src/routes/checkbox/index.mdx | 6 +++ .../src/checkbox/checkbox-hidden-input.tsx | 23 ++++++++++ .../src/checkbox/checkbox.driver.ts | 7 ++- libs/components/src/checkbox/checkbox.test.ts | 35 +++++++++++++++ libs/components/src/checkbox/index.ts | 1 + .../src/visually-hidden/visually-hidden.tsx | 23 ++++++++++ 7 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/routes/checkbox/examples/form.tsx create mode 100644 libs/components/src/checkbox/checkbox-hidden-input.tsx create mode 100644 libs/components/src/visually-hidden/visually-hidden.tsx diff --git a/apps/docs/src/routes/checkbox/examples/form.tsx b/apps/docs/src/routes/checkbox/examples/form.tsx new file mode 100644 index 0000000..0d21617 --- /dev/null +++ b/apps/docs/src/routes/checkbox/examples/form.tsx @@ -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>(); + + return ( +
{ + const form = e.target as HTMLFormElement; + formData.value = Object.fromEntries(new FormData(form)); + }} + style={{ display: "flex", flexDirection: "column", gap: "8px" }} + > + + + {formData.value &&
Submitted: {JSON.stringify(formData.value, null, 2)}
} + + ); +}); + +export const TermsCheckbox = component$(() => { + return ( + + +
+ + + + + + I accept the Terms and Conditions +
+
+ ); +}); + +// example styles +import styles from "./checkbox.css?inline"; diff --git a/apps/docs/src/routes/checkbox/index.mdx b/apps/docs/src/routes/checkbox/index.mdx index 4ab657b..e08bb3f 100644 --- a/apps/docs/src/routes/checkbox/index.mdx +++ b/apps/docs/src/routes/checkbox/index.mdx @@ -73,4 +73,10 @@ The mixed state can also be set reactively > > This follows the standard accessibility pattern where mixed → checked → unchecked → checked → unchecked, and so on. +## Forms + +To create a form with a checkbox, use the `Checkbox.HiddenNativeInput` component. + + + diff --git a/libs/components/src/checkbox/checkbox-hidden-input.tsx b/libs/components/src/checkbox/checkbox-hidden-input.tsx new file mode 100644 index 0000000..b6427ef --- /dev/null +++ b/libs/components/src/checkbox/checkbox-hidden-input.tsx @@ -0,0 +1,23 @@ +import { component$, useContext, type PropsOf } from "@builder.io/qwik"; +import { VisuallyHidden } from "../visually-hidden/visually-hidden"; +import { checkboxContextId } from "./checkbox-context"; + +type CheckboxHiddenNativeInputProps = PropsOf<"input">; + +export const CheckboxHiddenNativeInput = component$( + (props: CheckboxHiddenNativeInputProps) => { + const context = useContext(checkboxContextId); + + return ( + + + + ); + } +); diff --git a/libs/components/src/checkbox/checkbox.driver.ts b/libs/components/src/checkbox/checkbox.driver.ts index 8f515d5..56a5149 100644 --- a/libs/components/src/checkbox/checkbox.driver.ts +++ b/libs/components/src/checkbox/checkbox.driver.ts @@ -18,12 +18,17 @@ export function createTestDriver(rootLocator: T) { return rootLocator.locator("[data-qds-checkbox-label]"); }; + const getHiddenInput = () => { + return rootLocator.locator("[data-qds-checkbox-hidden-input]"); + }; + return { ...rootLocator, locator: rootLocator, getRoot, getIndicator, getTrigger, - getLabel + getLabel, + getHiddenInput }; } diff --git a/libs/components/src/checkbox/checkbox.test.ts b/libs/components/src/checkbox/checkbox.test.ts index 7515450..d2164e4 100644 --- a/libs/components/src/checkbox/checkbox.test.ts +++ b/libs/components/src/checkbox/checkbox.test.ts @@ -262,3 +262,38 @@ test.describe("a11y", () => { await expect(d.getIndicator()).toBeHidden(); }); }); + +test.describe("forms", () => { + test(`GIVEN a checkbox inside a form + WHEN the checkbox is rendered + THEN there should be a hidden input + `, async ({ page }) => { + const d = await setup(page, "form"); + + await expect(d.getHiddenInput()).toBeVisible(); + }); + + test(`GIVEN a checkbox inside a form + WHEN the checkbox is checked + THEN the hidden input should be checked +`, async ({ page }) => { + const d = await setup(page, "form"); + + await d.getTrigger().click(); + await expect(d.getHiddenInput()).toBeChecked(); + }); + + test(`GIVEN a checkbox inside a form that is initially checked + WHEN the checkbox is checked + THEN the hidden input should be unchecked +`, async ({ page }) => { + const d = await setup(page, "form"); + + // initial setup + await d.getTrigger().click(); + await expect(d.getHiddenInput()).toBeChecked(); + + await d.getTrigger().click(); + await expect(d.getHiddenInput()).not.toBeChecked(); + }); +}); diff --git a/libs/components/src/checkbox/index.ts b/libs/components/src/checkbox/index.ts index f426954..b339300 100644 --- a/libs/components/src/checkbox/index.ts +++ b/libs/components/src/checkbox/index.ts @@ -3,3 +3,4 @@ export { CheckboxIndicator as Indicator } from "./checkbox-indicator"; export { CheckboxTrigger as Trigger } from "./checkbox-trigger"; export { CheckboxLabel as Label } from "./checkbox-label"; export { CheckboxDescription as Description } from "./checkbox-description"; +export { CheckboxHiddenNativeInput as HiddenNativeInput } from "./checkbox-hidden-input"; diff --git a/libs/components/src/visually-hidden/visually-hidden.tsx b/libs/components/src/visually-hidden/visually-hidden.tsx new file mode 100644 index 0000000..20449fe --- /dev/null +++ b/libs/components/src/visually-hidden/visually-hidden.tsx @@ -0,0 +1,23 @@ +import { type PropsOf, Slot, component$, useStylesScoped$ } from "@builder.io/qwik"; + +export const VisuallyHidden = component$((props: PropsOf<"span">) => { + /* Visually hide text while keeping it accessible */ + /* Source: https://www.scottohara.me/blog/2017/04/14/inclusively-hidden.html */ + useStylesScoped$(` + + .visually-hidden:not(:focus):not(:active) { + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; + } +`); + + return ( + + + + ); +}); From 77075605d00121ac05e5955b50d4e882a5b664ee Mon Sep 17 00:00:00 2001 From: jack shelton Date: Sat, 23 Nov 2024 16:46:56 -0600 Subject: [PATCH 07/13] feat: indeterminate support --- .../routes/checkbox/examples/form-mixed.tsx | 54 +++++++++++++++++++ libs/components/src/checkbox/checkbox.test.ts | 10 ++++ 2 files changed, 64 insertions(+) create mode 100644 apps/docs/src/routes/checkbox/examples/form-mixed.tsx diff --git a/apps/docs/src/routes/checkbox/examples/form-mixed.tsx b/apps/docs/src/routes/checkbox/examples/form-mixed.tsx new file mode 100644 index 0000000..d664bdb --- /dev/null +++ b/apps/docs/src/routes/checkbox/examples/form-mixed.tsx @@ -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>(); + + const isChecked = useSignal("mixed"); + + return ( +
{ + const form = e.target as HTMLFormElement; + formData.value = Object.fromEntries(new FormData(form)); + }} + style={{ display: "flex", flexDirection: "column", gap: "8px" }} + > + + + + ); +}); + +export const TermsCheckbox = component$( + ({ isChecked }: { isChecked: Signal }) => { + return ( + + +
+ + + + + + I accept the Terms and Conditions +
+
+ ); + } +); + +// example styles +import styles from "./checkbox.css?inline"; diff --git a/libs/components/src/checkbox/checkbox.test.ts b/libs/components/src/checkbox/checkbox.test.ts index d2164e4..10a017c 100644 --- a/libs/components/src/checkbox/checkbox.test.ts +++ b/libs/components/src/checkbox/checkbox.test.ts @@ -296,4 +296,14 @@ test.describe("forms", () => { await d.getTrigger().click(); await expect(d.getHiddenInput()).not.toBeChecked(); }); + + test(`GIVEN a checkbox inside a form + WHEN the checkbox is mixed state + THEN the hidden input should be indeterminate +`, async ({ page }) => { + const d = await setup(page, "form-mixed"); + + await page.locator("[data-test-mixed]").click(); + await expect(d.getHiddenInput()).toHaveAttribute("indeterminate"); + }); }); From 6c2aa478cff74c53ee189a6b2ede89b43532ee91 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Sat, 23 Nov 2024 20:42:17 -0600 Subject: [PATCH 08/13] simpler required support --- .../src/routes/checkbox/examples/checkbox.css | 4 +- .../src/routes/checkbox/examples/form.tsx | 2 +- .../checkbox/examples/required-custom.tsx | 56 +++++++++++++++++++ .../src/routes/checkbox/examples/required.tsx | 43 ++++++++++++++ apps/docs/src/routes/checkbox/index.mdx | 20 ++++++- .../src/checkbox/checkbox-context.tsx | 3 + .../src/checkbox/checkbox-hidden-input.tsx | 3 + .../components/src/checkbox/checkbox-root.tsx | 16 +++++- libs/components/src/checkbox/checkbox.test.ts | 23 ++++++++ 9 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 apps/docs/src/routes/checkbox/examples/required-custom.tsx create mode 100644 apps/docs/src/routes/checkbox/examples/required.tsx diff --git a/apps/docs/src/routes/checkbox/examples/checkbox.css b/apps/docs/src/routes/checkbox/examples/checkbox.css index b58a594..1963914 100644 --- a/apps/docs/src/routes/checkbox/examples/checkbox.css +++ b/apps/docs/src/routes/checkbox/examples/checkbox.css @@ -5,11 +5,11 @@ } .checkbox-trigger { - background: #0a4d70; width: 30px; height: 30px; border-radius: 8px; position: relative; + background: gray; } .checkbox-trigger:focus-visible { @@ -26,4 +26,6 @@ position: absolute; inset: 0; align-items: center; + background: #0a4d70; + border-radius: 8px; } \ No newline at end of file diff --git a/apps/docs/src/routes/checkbox/examples/form.tsx b/apps/docs/src/routes/checkbox/examples/form.tsx index 0d21617..327d161 100644 --- a/apps/docs/src/routes/checkbox/examples/form.tsx +++ b/apps/docs/src/routes/checkbox/examples/form.tsx @@ -25,7 +25,7 @@ export default component$(() => { export const TermsCheckbox = component$(() => { return ( - +
diff --git a/apps/docs/src/routes/checkbox/examples/required-custom.tsx b/apps/docs/src/routes/checkbox/examples/required-custom.tsx new file mode 100644 index 0000000..3f1a8fa --- /dev/null +++ b/apps/docs/src/routes/checkbox/examples/required-custom.tsx @@ -0,0 +1,56 @@ +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>(); + const isChecked = useSignal(false); + const isError = useSignal(false); + + return ( +
{ + if (!isChecked.value) { + isError.value = true; + return; + } + isError.value = false; + const form = e.target as HTMLFormElement; + formData.value = Object.fromEntries(new FormData(form)); + }} + style={{ display: "flex", flexDirection: "column", gap: "8px" }} + > + + {isError.value && ( +
Please accept the terms and conditions
+ )} + + {formData.value &&
Submitted: {JSON.stringify(formData.value, null, 2)}
} + + ); +}); + +export const TermsCheckbox = component$( + ({ isChecked }: { isChecked: Signal }) => { + return ( + + +
+ + + + + + I accept the Terms and Conditions +
+
+ ); + } +); + +// example styles +import styles from "./checkbox.css?inline"; diff --git a/apps/docs/src/routes/checkbox/examples/required.tsx b/apps/docs/src/routes/checkbox/examples/required.tsx new file mode 100644 index 0000000..6b8c64c --- /dev/null +++ b/apps/docs/src/routes/checkbox/examples/required.tsx @@ -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>(); + + return ( +
{ + const form = e.target as HTMLFormElement; + formData.value = Object.fromEntries(new FormData(form)); + }} + style={{ display: "flex", flexDirection: "column", gap: "8px" }} + > + + + {formData.value &&
Submitted: {JSON.stringify(formData.value, null, 2)}
} + + ); +}); + +export const TermsCheckbox = component$(() => { + return ( + + +
+ + + + + + I accept the Terms and Conditions +
+
+ ); +}); + +// example styles +import styles from "./checkbox.css?inline"; diff --git a/apps/docs/src/routes/checkbox/index.mdx b/apps/docs/src/routes/checkbox/index.mdx index e08bb3f..4eb78d6 100644 --- a/apps/docs/src/routes/checkbox/index.mdx +++ b/apps/docs/src/routes/checkbox/index.mdx @@ -73,10 +73,28 @@ The mixed state can also be set reactively > > This follows the standard accessibility pattern where mixed → checked → unchecked → checked → unchecked, and so on. -## Forms +## Inside a form To create a form with a checkbox, use the `Checkbox.HiddenNativeInput` component. +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. + + + +### Custom required validation + +> Any input props can also be passed to the hidden native input + + + + + diff --git a/libs/components/src/checkbox/checkbox-context.tsx b/libs/components/src/checkbox/checkbox-context.tsx index c798d07..4555405 100644 --- a/libs/components/src/checkbox/checkbox-context.tsx +++ b/libs/components/src/checkbox/checkbox-context.tsx @@ -7,4 +7,7 @@ export type CheckboxContext = { isDisabledSig: Signal; localId: string; isDescription: boolean | undefined; + name: string | undefined; + required: boolean | undefined; + value: string | undefined; }; diff --git a/libs/components/src/checkbox/checkbox-hidden-input.tsx b/libs/components/src/checkbox/checkbox-hidden-input.tsx index b6427ef..639f5c9 100644 --- a/libs/components/src/checkbox/checkbox-hidden-input.tsx +++ b/libs/components/src/checkbox/checkbox-hidden-input.tsx @@ -15,6 +15,9 @@ export const CheckboxHiddenNativeInput = component$( checked={context.isCheckedSig.value === true} indeterminate={context.isCheckedSig.value === "mixed"} data-qds-checkbox-hidden-input + name={context.name ?? props.name ?? undefined} + required={context.required ?? props.required ?? undefined} + value={context.value ?? props.value ?? undefined} {...props} /> diff --git a/libs/components/src/checkbox/checkbox-root.tsx b/libs/components/src/checkbox/checkbox-root.tsx index eb6326f..969ff20 100644 --- a/libs/components/src/checkbox/checkbox-root.tsx +++ b/libs/components/src/checkbox/checkbox-root.tsx @@ -20,6 +20,9 @@ export type CheckboxRootProps = { onChange$?: QRL<(checked: T) => void>; disabled?: boolean; isDescription?: boolean; + name?: string; + required?: boolean; + value?: string; } & PropsOf<"div">; export const CheckboxRoot = component$((props: CheckboxRootProps) => { @@ -29,10 +32,16 @@ export const CheckboxRoot = component$((props: CheckboxRootProps) => { onClick$, onChange$, isDescription, + name, + required, + value, ...rest } = props; - const isCheckedSig = useBoundSignal(givenCheckedSig, checked ?? false); + const isCheckedSig = useBoundSignal( + givenCheckedSig, + checked ?? false + ); const isInitialLoadSig = useSignal(true); const isDisabledSig = useComputed$(() => props.disabled); const localId = useId(); @@ -41,7 +50,10 @@ export const CheckboxRoot = component$((props: CheckboxRootProps) => { isCheckedSig, isDisabledSig, localId, - isDescription + isDescription, + name, + required, + value }; useContextProvider(checkboxContextId, context); diff --git a/libs/components/src/checkbox/checkbox.test.ts b/libs/components/src/checkbox/checkbox.test.ts index 10a017c..010b1d6 100644 --- a/libs/components/src/checkbox/checkbox.test.ts +++ b/libs/components/src/checkbox/checkbox.test.ts @@ -306,4 +306,27 @@ test.describe("forms", () => { await page.locator("[data-test-mixed]").click(); await expect(d.getHiddenInput()).toHaveAttribute("indeterminate"); }); + + test(`GIVEN a checkbox inside a form that is checked + WHEN the submit button is clicked + THEN the form should be submitted +`, async ({ page }) => { + const d = await setup(page, "form"); + + // initial setup + await d.getTrigger().click(); + + await page.getByRole("button").last().click(); + await expect(page.getByText("Submitted")).toBeVisible(); + }); + + test(`GIVEN a checkbox inside a form + WHEN the submit button is clicked + THEN the form should not be submitted +`, async ({ page }) => { + await setup(page, "form"); + + await page.getByRole("button").last().click(); + await expect(page.getByText("Submitted")).not.toBeVisible(); + }); }); From cb01be25ffb950dd2d2f1187ea652b6f37e145cb Mon Sep 17 00:00:00 2001 From: jack shelton Date: Sat, 23 Nov 2024 21:42:55 -0600 Subject: [PATCH 09/13] add validation --- .../checkbox/examples/required-custom.tsx | 56 --------------- .../routes/checkbox/examples/validation.tsx | 70 +++++++++++++++++++ .../src/routes/checkbox/examples/value.tsx | 43 ++++++++++++ apps/docs/src/routes/checkbox/index.mdx | 13 +++- .../src/checkbox/checkbox-context.tsx | 2 + .../src/checkbox/checkbox-error-message.tsx | 23 ++++++ .../src/checkbox/checkbox-hidden-input.tsx | 2 + .../components/src/checkbox/checkbox-root.tsx | 6 +- .../src/checkbox/checkbox-trigger.tsx | 26 ++++++- .../src/checkbox/checkbox.driver.ts | 7 +- libs/components/src/checkbox/index.ts | 1 + 11 files changed, 186 insertions(+), 63 deletions(-) delete mode 100644 apps/docs/src/routes/checkbox/examples/required-custom.tsx create mode 100644 apps/docs/src/routes/checkbox/examples/validation.tsx create mode 100644 apps/docs/src/routes/checkbox/examples/value.tsx create mode 100644 libs/components/src/checkbox/checkbox-error-message.tsx diff --git a/apps/docs/src/routes/checkbox/examples/required-custom.tsx b/apps/docs/src/routes/checkbox/examples/required-custom.tsx deleted file mode 100644 index 3f1a8fa..0000000 --- a/apps/docs/src/routes/checkbox/examples/required-custom.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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>(); - const isChecked = useSignal(false); - const isError = useSignal(false); - - return ( -
{ - if (!isChecked.value) { - isError.value = true; - return; - } - isError.value = false; - const form = e.target as HTMLFormElement; - formData.value = Object.fromEntries(new FormData(form)); - }} - style={{ display: "flex", flexDirection: "column", gap: "8px" }} - > - - {isError.value && ( -
Please accept the terms and conditions
- )} - - {formData.value &&
Submitted: {JSON.stringify(formData.value, null, 2)}
} - - ); -}); - -export const TermsCheckbox = component$( - ({ isChecked }: { isChecked: Signal }) => { - return ( - - -
- - - - - - I accept the Terms and Conditions -
-
- ); - } -); - -// example styles -import styles from "./checkbox.css?inline"; diff --git a/apps/docs/src/routes/checkbox/examples/validation.tsx b/apps/docs/src/routes/checkbox/examples/validation.tsx new file mode 100644 index 0000000..0a6b262 --- /dev/null +++ b/apps/docs/src/routes/checkbox/examples/validation.tsx @@ -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>(); + const isChecked = useSignal(false); + const isSubmitAttempt = useSignal(false); + const isError = useComputed$(() => !isChecked.value && isSubmitAttempt.value); + + return ( +
{ + 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" }} + > + + + {formData.value &&
Submitted: {JSON.stringify(formData.value, null, 2)}
} + + ); +}); + +type TermsCheckboxProps = { + isChecked: Signal; + isError: Signal; +}; + +export const TermsCheckbox = component$(({ isChecked, isError }: TermsCheckboxProps) => { + return ( + + +
+ + + + + + I accept the Terms and Conditions +
+ {isError.value && ( + + Please accept the terms and conditions + + )} +
+ ); +}); + +// example styles +import styles from "./checkbox.css?inline"; diff --git a/apps/docs/src/routes/checkbox/examples/value.tsx b/apps/docs/src/routes/checkbox/examples/value.tsx new file mode 100644 index 0000000..2524908 --- /dev/null +++ b/apps/docs/src/routes/checkbox/examples/value.tsx @@ -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>(); + + return ( +
{ + const form = e.target as HTMLFormElement; + formData.value = Object.fromEntries(new FormData(form)); + }} + style={{ display: "flex", flexDirection: "column", gap: "8px" }} + > + + + {formData.value &&
Submitted: {JSON.stringify(formData.value, null, 2)}
} + + ); +}); + +export const TermsCheckbox = component$(() => { + return ( + + +
+ + + + + + I accept the Terms and Conditions +
+
+ ); +}); + +// example styles +import styles from "./checkbox.css?inline"; diff --git a/apps/docs/src/routes/checkbox/index.mdx b/apps/docs/src/routes/checkbox/index.mdx index 4eb78d6..08a165f 100644 --- a/apps/docs/src/routes/checkbox/index.mdx +++ b/apps/docs/src/routes/checkbox/index.mdx @@ -89,12 +89,19 @@ To make a checkbox required, pass `required` to the `Checkbox.Root` component. -### Custom required validation +## Giving a checkbox a value -> Any input props can also be passed to the hidden native input +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. - + +## 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. + + diff --git a/libs/components/src/checkbox/checkbox-context.tsx b/libs/components/src/checkbox/checkbox-context.tsx index 4555405..42a0e47 100644 --- a/libs/components/src/checkbox/checkbox-context.tsx +++ b/libs/components/src/checkbox/checkbox-context.tsx @@ -5,9 +5,11 @@ export const checkboxContextId = createContextId("qds-checkbox- export type CheckboxContext = { isCheckedSig: Signal; isDisabledSig: Signal; + isErrorSig: Signal; localId: string; isDescription: boolean | undefined; name: string | undefined; required: boolean | undefined; value: string | undefined; + triggerRef: Signal; }; diff --git a/libs/components/src/checkbox/checkbox-error-message.tsx b/libs/components/src/checkbox/checkbox-error-message.tsx new file mode 100644 index 0000000..f27fc10 --- /dev/null +++ b/libs/components/src/checkbox/checkbox-error-message.tsx @@ -0,0 +1,23 @@ +import { component$, Slot, useContext, useTask$, type PropsOf } from "@builder.io/qwik"; +import { checkboxContextId } from "./checkbox-context"; + +type CheckboxErrorMessageProps = PropsOf<"div">; + +export const CheckboxErrorMessage = component$((props: CheckboxErrorMessageProps) => { + const context = useContext(checkboxContextId); + const errorId = `${context.localId}-error`; + + useTask$(({ cleanup }) => { + context.isErrorSig.value = true; + + cleanup(() => { + context.isErrorSig.value = false; + }); + }); + + return ( +
+ +
+ ); +}); diff --git a/libs/components/src/checkbox/checkbox-hidden-input.tsx b/libs/components/src/checkbox/checkbox-hidden-input.tsx index 639f5c9..2c127d3 100644 --- a/libs/components/src/checkbox/checkbox-hidden-input.tsx +++ b/libs/components/src/checkbox/checkbox-hidden-input.tsx @@ -12,6 +12,8 @@ export const CheckboxHiddenNativeInput = component$( context.triggerRef.value?.focus()} checked={context.isCheckedSig.value === true} indeterminate={context.isCheckedSig.value === "mixed"} data-qds-checkbox-hidden-input diff --git a/libs/components/src/checkbox/checkbox-root.tsx b/libs/components/src/checkbox/checkbox-root.tsx index 969ff20..ec1a93f 100644 --- a/libs/components/src/checkbox/checkbox-root.tsx +++ b/libs/components/src/checkbox/checkbox-root.tsx @@ -44,7 +44,9 @@ export const CheckboxRoot = component$((props: CheckboxRootProps) => { ); const isInitialLoadSig = useSignal(true); const isDisabledSig = useComputed$(() => props.disabled); + const isErrorSig = useSignal(false); const localId = useId(); + const triggerRef = useSignal(); const context: CheckboxContext = { isCheckedSig, @@ -53,7 +55,9 @@ export const CheckboxRoot = component$((props: CheckboxRootProps) => { isDescription, name, required, - value + value, + isErrorSig, + triggerRef }; useContextProvider(checkboxContextId, context); diff --git a/libs/components/src/checkbox/checkbox-trigger.tsx b/libs/components/src/checkbox/checkbox-trigger.tsx index c2ebb57..b6906b2 100644 --- a/libs/components/src/checkbox/checkbox-trigger.tsx +++ b/libs/components/src/checkbox/checkbox-trigger.tsx @@ -1,4 +1,12 @@ -import { $, component$, type PropsOf, Slot, sync$, useContext } from "@builder.io/qwik"; +import { + $, + component$, + type PropsOf, + Slot, + sync$, + useComputed$, + useContext +} from "@builder.io/qwik"; import { checkboxContextId } from "./checkbox-context"; type CheckboxControlProps = PropsOf<"button">; @@ -7,6 +15,18 @@ export const CheckboxTrigger = component$((props: CheckboxControlProps) => { const context = useContext(checkboxContextId); const triggerId = `${context.localId}-trigger`; const descriptionId = `${context.localId}-description`; + const errorId = `${context.localId}-error`; + + const describedByLabels = useComputed$(() => { + const labels = []; + if (context.isDescription) { + labels.push(descriptionId); + } + if (context.isErrorSig.value) { + labels.push(errorId); + } + return labels.join(" ") || undefined; + }); const handleClick$ = $(() => { if (context.isCheckedSig.value === "mixed") { @@ -25,10 +45,12 @@ export const CheckboxTrigger = component$((props: CheckboxControlProps) => { return (