From c12b93a26aa4538b52cf84ef3a0d463e3afc4885 Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Mon, 1 May 2023 22:14:28 +0200 Subject: [PATCH 1/7] chore(tailwind): remove colors --- packages/tailwindcss/src/colors.ts | 342 ----------------------------- packages/tailwindcss/src/index.ts | 106 ++++----- 2 files changed, 43 insertions(+), 405 deletions(-) delete mode 100644 packages/tailwindcss/src/colors.ts diff --git a/packages/tailwindcss/src/colors.ts b/packages/tailwindcss/src/colors.ts deleted file mode 100644 index 098f1077..00000000 --- a/packages/tailwindcss/src/colors.ts +++ /dev/null @@ -1,342 +0,0 @@ -/*! - * Portions of this file are based on code from tailwindcss. - * MIT Licensed, Copyright (c) 2020 Tailwind Labs. - * - * Credits to the Tailwind Labs team: - * https://github.com/tailwindlabs/tailwindcss/blob/7f555c432d7f801fcac82fbf1331a5ed8986c4c1/src/public/colors.js - */ - -export const DEFAULT_COLORS = { - inherit: "inherit", - current: "currentColor", - transparent: "transparent", - black: "#000", - white: "#fff", - - /* Pure gray. */ - gray: { - 50: "#fafafa", - 100: "#f5f5f5", - 200: "#e6e6e6", - 300: "#d4d4d4", - 400: "#a3a3a3", - 500: "#737373", - 600: "#525252", - 700: "#404040", - 800: "#262626", - 900: "#171717", - 950: "#0a0a0a", - }, - - /* Grayscale based on desaturated purple hues - pair with violet/purple/fuchsia. */ - mauve: { - 50: "#fafafa", - 100: "#f5f4f5", - 200: "#e6e4e7", - 300: "#d6d4d8", - 400: "#a6a1aa", - 500: "#75717a", - 600: "#57525b", - 700: "#433f46", - 800: "#29272a", - 900: "#1a181b", - 950: "#0a090b", - }, - - /* Grayscale based on desaturated blue hues - pair with indigo/blue/sky/cyan. */ - slate: { - 50: "#fafafa", - 100: "#f4f5f5", - 200: "#e4e6e7", - 300: "#d4d6d8", - 400: "#a1a5aa", - 500: "#71747a", - 600: "#52555b", - 700: "#3f4146", - 800: "#27282a", - 900: "#18191b", - 950: "#090a0b", - }, - - /* Grayscale based on desaturated green hues - pair with teal/emerald/green. */ - sage: { - 50: "#fafafa", - 100: "#f4f5f5", - 200: "#e4e7e5", - 300: "#d4d8d6", - 400: "#a1aaa4", - 500: "#717a74", - 600: "#525b55", - 700: "#3f4641", - 800: "#272a29", - 900: "#181b19", - 950: "#090b0a", - }, - - /* Grayscale based on desaturated lime hues - pair with green/lime. */ - olive: { - 50: "#fafafa", - 100: "#f5f5f4", - 200: "#e6e7e4", - 300: "#d7d8d4", - 400: "#a7aaa1", - 500: "#767a71", - 600: "#575b52", - 700: "#43463f", - 800: "#292a27", - 900: "#1a1b18", - 950: "#0a0b09", - }, - - /* Grayscale based on desaturated yellow hues - pair with orange/amber/yellow. */ - sand: { - 50: "#fafafa", - 100: "#f5f5f4", - 200: "#e7e7e4", - 300: "#d8d7d4", - 400: "#aaa8a1", - 500: "#7a7871", - 600: "#5b5852", - 700: "#46433f", - 800: "#2a2927", - 900: "#1b1918", - 950: "#0b0a09", - }, - - /* Grayscale based on desaturated red hues - pair with pink/rose/red. */ - maroon: { - 50: "#fafafa", - 100: "#f5f4f4", - 200: "#e7e4e4", - 300: "#d8d4d4", - 400: "#aaa1a1", - 500: "#7a7171", - 600: "#5b5252", - 700: "#463f3f", - 800: "#2a2727", - 900: "#1b1818", - 950: "#0b0909", - }, - - green: { - 50: "#f2fdf5", - 100: "#defce9", - 200: "#bbf7d0", - 300: "#85efac", - 400: "#4ade80", - 500: "#21c45d", - 600: "#158e41", - 700: "#157f3c", - 800: "#166434", - 900: "#14522d", - 950: "#052e16", - }, - emerald: { - 50: "#edfdf5", - 100: "#d1fae5", - 200: "#a5f3cf", - 300: "#6ee7b7", - 400: "#36d399", - 500: "#10b77f", - 600: "#049064", - 700: "#047756", - 800: "#066046", - 900: "#064c39", - 950: "#022c22", - }, - teal: { - 50: "#f2fdfa", - 100: "#cbfbf0", - 200: "#98f6e3", - 300: "#5dead5", - 400: "#2bd4bd", - 500: "#14b8a5", - 600: "#0d8c81", - 700: "#0f756d", - 800: "#115f5a", - 900: "#134e4a", - 950: "#042f2e", - }, - cyan: { - 50: "#ebfeff", - 100: "#cdfafe", - 200: "#a6f4fc", - 300: "#67e8f9", - 400: "#20d3ee", - 500: "#07b6d5", - 600: "#0886a6", - 700: "#0e7490", - 800: "#155f75", - 900: "#164f64", - 950: "#083344", - }, - sky: { - 50: "#f0f9ff", - 100: "#e1f3fe", - 200: "#bae5fd", - 300: "#7ed4fc", - 400: "#3abff8", - 500: "#0da2e7", - 600: "#027dbb", - 700: "#0369a0", - 800: "#075783", - 900: "#0c4a6e", - 950: "#082f49", - }, - blue: { - 50: "#f0f6ff", - 100: "#dcebfe", - 200: "#bedbfe", - 300: "#91c3fd", - 400: "#61a6fa", - 500: "#3c83f6", - 600: "#2463eb", - 700: "#1d4fd7", - 800: "#1e3fae", - 900: "#1e3b8a", - 950: "#172554", - }, - indigo: { - 50: "#f0f3ff", - 100: "#e0e8ff", - 200: "#c8d3fe", - 300: "#a6b4fc", - 400: "#828df8", - 500: "#6467f2", - 600: "#5048e5", - 700: "#463acb", - 800: "#372fa2", - 900: "#312e7f", - 950: "#1e1b4b", - }, - violet: { - 50: "#f7f5ff", - 100: "#ebe7fe", - 200: "#ded7fe", - 300: "#c3b4fd", - 400: "#a689fa", - 500: "#895af6", - 600: "#7c3bed", - 700: "#6b26d9", - 800: "#5a21b5", - 900: "#4d1d95", - 950: "#2e1065", - }, - purple: { - 50: "#faf5ff", - 100: "#f2e6ff", - 200: "#ead6ff", - 300: "#d8b4fe", - 400: "#bf83fc", - 500: "#a855f7", - 600: "#9234ea", - 700: "#7e22ce", - 800: "#6a21a6", - 900: "#591c87", - 950: "#3c0764", - }, - fuchsia: { - 50: "#fdf5ff", - 100: "#f9e6ff", - 200: "#f5d2fe", - 300: "#f0abfc", - 400: "#e87bf9", - 500: "#d948ef", - 600: "#bf27d3", - 700: "#a31daf", - 800: "#85198f", - 900: "#711a75", - 950: "#4a044e", - }, - pink: { - 50: "#fdf2f8", - 100: "#fce8f4", - 200: "#fbd0e8", - 300: "#f9a9d5", - 400: "#f471b5", - 500: "#ec4699", - 600: "#db2979", - 700: "#bf185d", - 800: "#9b174c", - 900: "#811842", - 950: "#500724", - }, - rose: { - 50: "#fff0f1", - 100: "#ffe6e7", - 200: "#fecdd3", - 300: "#fda5af", - 400: "#fb6f84", - 500: "#f43e5c", - 600: "#e21d48", - 700: "#bf123d", - 800: "#a1123a", - 900: "#861336", - 950: "#4d0519", - }, - red: { - 50: "#fef1f1", - 100: "#fee1e1", - 200: "#fec8c8", - 300: "#fca6a6", - 400: "#f87272", - 500: "#ef4343", - 600: "#dc2828", - 700: "#ba1c1c", - 800: "#981b1b", - 900: "#811d1d", - 950: "#430a0a", - }, - orange: { - 50: "#fff6eb", - 100: "#ffedd6", - 200: "#fed6a9", - 300: "#fdba72", - 400: "#fb923c", - 500: "#f97415", - 600: "#d95108", - 700: "#c03f0c", - 800: "#9b3412", - 900: "#7d2d12", - 950: "#451507", - }, - amber: { - 50: "#fffbeb", - 100: "#fef3c8", - 200: "#fde68b", - 300: "#fcd44f", - 400: "#fbbd23", - 500: "#f59f0a", - 600: "#db7706", - 700: "#b35309", - 800: "#91400d", - 900: "#76350f", - 950: "#451a03", - }, - yellow: { - 50: "#fefce7", - 100: "#fef9c3", - 200: "#fef08b", - 300: "#fddf49", - 400: "#facc14", - 500: "#e7b008", - 600: "#c88a04", - 700: "#a26107", - 800: "#864e0e", - 900: "#733f12", - 950: "#412006", - }, - lime: { - 50: "#f7fee7", - 100: "#ebfcca", - 200: "#d9f99f", - 300: "#bef263", - 400: "#a1e633", - 500: "#82cb15", - 600: "#66a50d", - 700: "#4c7b0f", - 800: "#406312", - 900: "#355214", - 950: "#1a2e05", - }, -}; diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index cf75f679..db18087c 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -8,8 +8,6 @@ import plugin from "tailwindcss/plugin"; -import { DEFAULT_COLORS } from "./colors"; - const STATES = [ "valid", "invalid", @@ -33,70 +31,52 @@ const SWIPE_DIRECTIONS = ["up", "down", "left", "right"]; export interface KobalteTailwindPluginOptions { /** The prefix of generated classes. */ prefix?: string; - - /** Whether to include Kobalte UI color palettes in the Tailwind theme. */ - colors?: boolean; } -export default plugin.withOptions( - ({ prefix = "ui" } = {}) => { - return ({ addVariant }) => { - for (const state of STATES) { - addVariant(`${prefix}-${state}`, [`&[data-${state}]`]); - addVariant(`${prefix}-not-${state}`, [`&:not([data-${state}])`]); - addVariant(`${prefix}-group-${state}`, `:merge(.group)[data-${state}] &`); - addVariant(`${prefix}-peer-${state}`, `:merge(.peer)[data-${state}] ~ &`); - } - - for (const orientation of ORIENTATIONS) { - addVariant(`${prefix}-${orientation}`, [`&[data-orientation='${orientation}']`]); - addVariant(`${prefix}-not-${orientation}`, [`&:not([data-orientation='${orientation}'])`]); - addVariant( - `${prefix}-group-${orientation}`, - `:merge(.group)[data-orientation='${orientation}'] &` - ); - addVariant( - `${prefix}-peer-${orientation}`, - `:merge(.peer)[data-orientation='${orientation}'] ~ &` - ); - } +export default plugin.withOptions(({ prefix = "ui" } = {}) => { + return ({ addVariant }) => { + for (const state of STATES) { + addVariant(`${prefix}-${state}`, [`&[data-${state}]`]); + addVariant(`${prefix}-not-${state}`, [`&:not([data-${state}])`]); + addVariant(`${prefix}-group-${state}`, `:merge(.group)[data-${state}] &`); + addVariant(`${prefix}-peer-${state}`, `:merge(.peer)[data-${state}] ~ &`); + } - for (const state of SWIPE_STATES) { - addVariant(`${prefix}-swipe-${state}`, [`&[data-swipe='${state}']`]); - addVariant(`${prefix}-not-swipe-${state}`, [`&:not([data-swipe='${state}'])`]); - addVariant(`${prefix}-group-swipe-${state}`, `:merge(.group)[data-swipe='${state}'] &`); - addVariant(`${prefix}-peer-swipe-${state}`, `:merge(.peer)[data-swipe='${state}'] ~ &`); - } + for (const orientation of ORIENTATIONS) { + addVariant(`${prefix}-${orientation}`, [`&[data-orientation='${orientation}']`]); + addVariant(`${prefix}-not-${orientation}`, [`&:not([data-orientation='${orientation}'])`]); + addVariant( + `${prefix}-group-${orientation}`, + `:merge(.group)[data-orientation='${orientation}'] &` + ); + addVariant( + `${prefix}-peer-${orientation}`, + `:merge(.peer)[data-orientation='${orientation}'] ~ &` + ); + } - for (const direction of SWIPE_DIRECTIONS) { - addVariant(`${prefix}-swipe-direction-${direction}`, [ - `&[data-swipe-direction='${direction}']`, - ]); - addVariant(`${prefix}-not-swipe-direction-${direction}`, [ - `&:not([data-swipe-direction='${direction}'])`, - ]); - addVariant( - `${prefix}-group-swipe-direction-${direction}`, - `:merge(.group)[data-swipe-direction='${direction}'] &` - ); - addVariant( - `${prefix}-peer-swipe-direction-${direction}`, - `:merge(.peer)[data-swipe-direction='${direction}'] ~ &` - ); - } - }; - }, - function ({ colors = false } = {}) { - if (!colors) { - return {}; + for (const state of SWIPE_STATES) { + addVariant(`${prefix}-swipe-${state}`, [`&[data-swipe='${state}']`]); + addVariant(`${prefix}-not-swipe-${state}`, [`&:not([data-swipe='${state}'])`]); + addVariant(`${prefix}-group-swipe-${state}`, `:merge(.group)[data-swipe='${state}'] &`); + addVariant(`${prefix}-peer-swipe-${state}`, `:merge(.peer)[data-swipe='${state}'] ~ &`); } - return { - theme: { - colors: { - ...DEFAULT_COLORS, - }, - }, - }; - } -); + for (const direction of SWIPE_DIRECTIONS) { + addVariant(`${prefix}-swipe-direction-${direction}`, [ + `&[data-swipe-direction='${direction}']`, + ]); + addVariant(`${prefix}-not-swipe-direction-${direction}`, [ + `&:not([data-swipe-direction='${direction}'])`, + ]); + addVariant( + `${prefix}-group-swipe-direction-${direction}`, + `:merge(.group)[data-swipe-direction='${direction}'] &` + ); + addVariant( + `${prefix}-peer-swipe-direction-${direction}`, + `:merge(.peer)[data-swipe-direction='${direction}'] ~ &` + ); + } + }; +}); From 7b3f414d74ac12edd17b732b184f026268356e59 Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Thu, 4 May 2023 08:59:13 +0200 Subject: [PATCH 2/7] fix: #205 --- .../radio-group/radio-group-item-context.tsx | 2 + .../core/src/radio-group/radio-group-item.tsx | 3 +- .../core/src/radio-group/radio-group.test.tsx | 46 +++++++++++++++++-- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/core/src/radio-group/radio-group-item-context.tsx b/packages/core/src/radio-group/radio-group-item-context.tsx index d8b1df8b..8bf260af 100644 --- a/packages/core/src/radio-group/radio-group-item-context.tsx +++ b/packages/core/src/radio-group/radio-group-item-context.tsx @@ -4,7 +4,9 @@ export interface RadioGroupItemDataSet { "data-valid": string | undefined; "data-invalid": string | undefined; "data-checked": string | undefined; + "data-required": string | undefined; "data-disabled": string | undefined; + "data-readonly": string | undefined; } export interface RadioGroupItemContextValue { diff --git a/packages/core/src/radio-group/radio-group-item.tsx b/packages/core/src/radio-group/radio-group-item.tsx index 1178bccc..590c4206 100644 --- a/packages/core/src/radio-group/radio-group-item.tsx +++ b/packages/core/src/radio-group/radio-group-item.tsx @@ -69,8 +69,7 @@ export function RadioGroupItem(props: RadioGroupItemProps) { }; const dataset: Accessor = createMemo(() => ({ - "data-valid": formControlContext.dataset()["data-valid"], - "data-invalid": formControlContext.dataset()["data-invalid"], + ...formControlContext.dataset(), "data-checked": isSelected() ? "" : undefined, "data-disabled": isDisabled() ? "" : undefined, })); diff --git a/packages/core/src/radio-group/radio-group.test.tsx b/packages/core/src/radio-group/radio-group.test.tsx index d1be38c7..7a440ccd 100644 --- a/packages/core/src/radio-group/radio-group.test.tsx +++ b/packages/core/src/radio-group/radio-group.test.tsx @@ -1312,9 +1312,9 @@ describe("RadioGroup", () => { } }); - it("should have 'data-checked' attribute on checked radio", async () => { + it("should have 'data-required' attribute on radios when radio group is required", async () => { render(() => ( - + @@ -1328,7 +1328,27 @@ describe("RadioGroup", () => { const elements = screen.getAllByTestId(/^radio/); for (const el of elements) { - expect(el).toHaveAttribute("data-checked"); + expect(el).toHaveAttribute("data-required"); + } + }); + + it("should have 'data-readonly' attribute on radios when radio group is readonly", async () => { + render(() => ( + + + + + + + Cats + + + )); + + const elements = screen.getAllByTestId(/^radio/); + + for (const el of elements) { + expect(el).toHaveAttribute("data-readonly"); } }); @@ -1371,6 +1391,26 @@ describe("RadioGroup", () => { expect(el).toHaveAttribute("data-disabled"); } }); + + it("should have 'data-checked' attribute on checked radio", async () => { + render(() => ( + + + + + + + Cats + + + )); + + const elements = screen.getAllByTestId(/^radio/); + + for (const el of elements) { + expect(el).toHaveAttribute("data-checked"); + } + }); }); }); }); From bed0895552aa8b9490e72dbd2bee5d2c7d70b658 Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Thu, 4 May 2023 09:23:41 +0200 Subject: [PATCH 3/7] fix: #206 --- apps/docs/src/examples/alert-dialog.tsx | 2 +- .../docs/core/components/alert-dialog.mdx | 17 +++++++++-------- .../routes/docs/core/components/combobox.mdx | 1 + .../docs/core/components/context-menu.mdx | 13 +++++++------ .../src/routes/docs/core/components/dialog.mdx | 17 +++++++++-------- .../docs/core/components/dropdown-menu.mdx | 17 +++++++++-------- .../src/routes/docs/core/components/popover.mdx | 17 +++++++++-------- .../src/routes/docs/core/components/select.mdx | 1 + packages/core/src/combobox/combobox-base.tsx | 6 ++++++ packages/core/src/combobox/combobox-content.tsx | 2 +- packages/core/src/combobox/combobox-context.tsx | 1 + packages/core/src/dialog/dialog-content.tsx | 2 +- packages/core/src/dialog/dialog-context.tsx | 1 + packages/core/src/dialog/dialog-root.tsx | 7 ++++++- packages/core/src/menu/menu-content.tsx | 2 +- packages/core/src/menu/menu-root-context.tsx | 1 + packages/core/src/menu/menu-root.tsx | 6 ++++++ packages/core/src/popover/popover-content.tsx | 8 ++++---- packages/core/src/popover/popover-context.tsx | 1 + packages/core/src/popover/popover-root.tsx | 8 +++++++- packages/core/src/select/select-base.tsx | 6 ++++++ packages/core/src/select/select-content.tsx | 2 +- packages/core/src/select/select-context.tsx | 1 + 23 files changed, 90 insertions(+), 49 deletions(-) diff --git a/apps/docs/src/examples/alert-dialog.tsx b/apps/docs/src/examples/alert-dialog.tsx index 91b4bc0e..cc079667 100644 --- a/apps/docs/src/examples/alert-dialog.tsx +++ b/apps/docs/src/examples/alert-dialog.tsx @@ -5,7 +5,7 @@ import style from "./alert-dialog.module.css"; export function BasicExample() { return ( - + Open diff --git a/apps/docs/src/routes/docs/core/components/alert-dialog.mdx b/apps/docs/src/routes/docs/core/components/alert-dialog.mdx index 01ac1bc4..db7ae590 100644 --- a/apps/docs/src/routes/docs/core/components/alert-dialog.mdx +++ b/apps/docs/src/routes/docs/core/components/alert-dialog.mdx @@ -260,14 +260,15 @@ function ControlledExample() { ### AlertDialog.Root -| Prop | Description | -| :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| open | `boolean`
The controlled open state of the dialog. | -| defaultOpen | `boolean`
The default open state when initially rendered. Useful when you do not need to control the open state. | -| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the dialog changes. | -| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. | -| modal | `boolean`
Whether the dialog should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the dialog content.
- elements outside the dialog content will not be visible for screen readers. | -| forceMount | `boolean`
Used to force mounting the dialog (portal, overlay and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | +| Prop | Description | +| :------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| open | `boolean`
The controlled open state of the dialog. | +| defaultOpen | `boolean`
The default open state when initially rendered. Useful when you do not need to control the open state. | +| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the dialog changes. | +| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. | +| modal | `boolean`
Whether the dialog should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the dialog content.
- elements outside the dialog content will not be visible for screen readers. | +| preventScroll | `boolean`
Whether the scroll should be locked even if the alert dialog is not modal. | +| forceMount | `boolean`
Used to force mounting the dialog (portal, overlay and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | ### AlertDialog.Trigger diff --git a/apps/docs/src/routes/docs/core/components/combobox.mdx b/apps/docs/src/routes/docs/core/components/combobox.mdx index 313c6d97..8569dee6 100644 --- a/apps/docs/src/routes/docs/core/components/combobox.mdx +++ b/apps/docs/src/routes/docs/core/components/combobox.mdx @@ -1102,6 +1102,7 @@ We expose a CSS custom property `--kb-combobox-content-transform-origin` which c | selectionBehavior | `'toggle' \| 'replace'`
How selection should behave in the combobox. | | virtualized | `boolean`
Whether the combobox uses virtual scrolling. | | modal | `boolean`
Whether the combobox should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the combobox content.
- elements outside the combobox content will not be visible for screen readers. | +| preventScroll | `boolean`
Whether the scroll should be locked even if the combobox is not modal. | | forceMount | `boolean`
Used to force mounting the combobox (portal, positioner and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | | name | `string`
The name of the combobox. Submitted with its owning form as part of a name/value pair. | | validationState | `'valid' \| 'invalid'`
Whether the combobox should display its "valid" or "invalid" visual styling. | diff --git a/apps/docs/src/routes/docs/core/components/context-menu.mdx b/apps/docs/src/routes/docs/core/components/context-menu.mdx index 3044fea9..c532c8de 100644 --- a/apps/docs/src/routes/docs/core/components/context-menu.mdx +++ b/apps/docs/src/routes/docs/core/components/context-menu.mdx @@ -402,12 +402,13 @@ We expose a CSS custom property `--kb-popper-content-transform-origin` which can ### ContextMenu.Root -| Prop | Description | -| :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the menu changes. | -| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. | -| modal | `boolean`
Whether the menu should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the menu content.
- elements outside the menu content will not be visible for screen readers. | -| forceMount | `boolean`
Used to force mounting the menu (portal and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | +| Prop | Description | +| :------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the menu changes. | +| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. | +| modal | `boolean`
Whether the menu should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the menu content.
- elements outside the menu content will not be visible for screen readers. | +| preventScroll | `boolean`
Whether the scroll should be locked even if the menu is not modal. | +| forceMount | `boolean`
Used to force mounting the menu (portal and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | `ContextMenu.Root` also accepts the following props to customize the placement of the `ContextMenu.Content`. diff --git a/apps/docs/src/routes/docs/core/components/dialog.mdx b/apps/docs/src/routes/docs/core/components/dialog.mdx index e760a353..c6f51a25 100644 --- a/apps/docs/src/routes/docs/core/components/dialog.mdx +++ b/apps/docs/src/routes/docs/core/components/dialog.mdx @@ -262,14 +262,15 @@ function ControlledExample() { ### Dialog.Root -| Prop | Description | -| :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| open | `boolean`
The controlled open state of the dialog. | -| defaultOpen | `boolean`
The default open state when initially rendered. Useful when you do not need to control the open state. | -| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the dialog changes. | -| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. | -| modal | `boolean`
Whether the dialog should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the dialog content.
- elements outside the dialog content will not be visible for screen readers. | -| forceMount | `boolean`
Used to force mounting the dialog (portal, overlay and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | +| Prop | Description | +| :------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| open | `boolean`
The controlled open state of the dialog. | +| defaultOpen | `boolean`
The default open state when initially rendered. Useful when you do not need to control the open state. | +| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the dialog changes. | +| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. | +| modal | `boolean`
Whether the dialog should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the dialog content.
- elements outside the dialog content will not be visible for screen readers. | +| preventScroll | `boolean`
Whether the scroll should be locked even if the dialog is not modal. | +| forceMount | `boolean`
Used to force mounting the dialog (portal, overlay and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | ### Dialog.Trigger diff --git a/apps/docs/src/routes/docs/core/components/dropdown-menu.mdx b/apps/docs/src/routes/docs/core/components/dropdown-menu.mdx index 23831d39..b9415c0e 100644 --- a/apps/docs/src/routes/docs/core/components/dropdown-menu.mdx +++ b/apps/docs/src/routes/docs/core/components/dropdown-menu.mdx @@ -470,14 +470,15 @@ We expose a CSS custom property `--kb-menu-content-transform-origin` which can b ### DropdownMenu.Root -| Prop | Description | -| :----------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| open | `boolean`
The controlled open state of the menu. | -| defaultOpen | `boolean`
The default open state when initially rendered. Useful when you do not need to control the open state. | -| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the menu changes. | -| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. | -| modal | `boolean`
Whether the menu should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the menu content.
- elements outside the menu content will not be visible for screen readers. | -| forceMount | `boolean`
Used to force mounting the menu (portal and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | +| Prop | Description | +| :------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| open | `boolean`
The controlled open state of the menu. | +| defaultOpen | `boolean`
The default open state when initially rendered. Useful when you do not need to control the open state. | +| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the menu changes. | +| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. | +| modal | `boolean`
Whether the menu should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the menu content.
- elements outside the menu content will not be visible for screen readers. | +| preventScroll | `boolean`
Whether the scroll should be locked even if the menu is not modal. | +| forceMount | `boolean`
Used to force mounting the menu (portal and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | `DropdownMenu.Root` also accepts the following props to customize the placement of the `DropdownMenu.Content`. diff --git a/apps/docs/src/routes/docs/core/components/popover.mdx b/apps/docs/src/routes/docs/core/components/popover.mdx index 15831e31..5bd9073d 100644 --- a/apps/docs/src/routes/docs/core/components/popover.mdx +++ b/apps/docs/src/routes/docs/core/components/popover.mdx @@ -288,14 +288,15 @@ We expose a CSS custom property `--kb-popover-content-transform-origin` which ca ### Popover.Root -| Prop | Description | -| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| open | `boolean`
The controlled open state of the popover. | -| defaultOpen | `boolean`
The default open state when initially rendered. Useful when you do not need to control the open state. | -| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the popover changes. | -| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. | -| modal | `boolean`
Whether the popover should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the popover content.
- elements outside the popover content will not be visible for screen readers. | -| forceMount | `boolean`
Used to force mounting the popover (portal and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | +| Prop | Description | +| :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| open | `boolean`
The controlled open state of the popover. | +| defaultOpen | `boolean`
The default open state when initially rendered. Useful when you do not need to control the open state. | +| onOpenChange | `(open: boolean) => void`
Event handler called when the open state of the popover changes. | +| id | `string`
A unique identifier for the component. The id is used to generate id attributes for nested components. If no id prop is provided, a generated id will be used. | +| modal | `boolean`
Whether the popover should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the popover content.
- elements outside the popover content will not be visible for screen readers. | +| preventScroll | `boolean`
Whether the scroll should be locked even if the popover is not modal. | +| forceMount | `boolean`
Used to force mounting the popover (portal and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | `Popover.Root` also accepts the following props to customize the placement of the `Popover.Content`. diff --git a/apps/docs/src/routes/docs/core/components/select.mdx b/apps/docs/src/routes/docs/core/components/select.mdx index 9ad4f0ca..cef5cda6 100644 --- a/apps/docs/src/routes/docs/core/components/select.mdx +++ b/apps/docs/src/routes/docs/core/components/select.mdx @@ -897,6 +897,7 @@ We expose a CSS custom property `--kb-select-content-transform-origin` which can | selectionBehavior | `'toggle' \| 'replace'`
How selection should behave in the select. | | virtualized | `boolean`
Whether the select uses virtual scrolling. | | modal | `boolean`
Whether the select should be the only visible content for screen readers, when set to `true`:
- interaction with outside elements will be disabled.
- scroll will be locked.
- focus will be locked inside the select content.
- elements outside the select content will not be visible for screen readers. | +| preventScroll | `boolean`
Whether the scroll should be locked even if the select is not modal. | | forceMount | `boolean`
Used to force mounting the select (portal, positioner and content) when more control is needed. Useful when controlling animation with SolidJS animation libraries. | | name | `string`
The name of the select. Submitted with its owning form as part of a name/value pair. | | validationState | `'valid' \| 'invalid'`
Whether the select should display its "valid" or "invalid" visual styling. | diff --git a/packages/core/src/combobox/combobox-base.tsx b/packages/core/src/combobox/combobox-base.tsx index e4b5921c..7e65278a 100644 --- a/packages/core/src/combobox/combobox-base.tsx +++ b/packages/core/src/combobox/combobox-base.tsx @@ -176,6 +176,9 @@ export interface ComboboxBaseOptions */ modal?: boolean; + /** Whether the scroll should be locked even if the combobox is not modal. */ + preventScroll?: boolean; + /** * Used to force mounting the combobox (portal, positioner and content) when more control is needed. * Useful when controlling animation with SolidJS animation libraries. @@ -231,6 +234,7 @@ export function ComboboxBase(props: ComboboxBaseProps< gutter: 8, sameWidth: true, modal: false, + preventScroll: false, triggerMode: "input", allowsEmptyCollection: false, }, @@ -267,6 +271,7 @@ export function ComboboxBase(props: ComboboxBaseProps< "selectionMode", "virtualized", "modal", + "preventScroll", "forceMount", ], [ @@ -643,6 +648,7 @@ export function ComboboxBase(props: ComboboxBaseProps< isMultiple: () => access(local.selectionMode) === "multiple", isVirtualized: () => local.virtualized ?? false, isModal: () => local.modal ?? false, + preventScroll: () => local.preventScroll ?? false, allowsEmptyCollection: () => local.allowsEmptyCollection ?? false, shouldFocusWrap: () => local.shouldFocusWrap ?? false, removeOnBackspace: () => local.removeOnBackspace ?? true, diff --git a/packages/core/src/combobox/combobox-content.tsx b/packages/core/src/combobox/combobox-content.tsx index b17e6fe8..8617e222 100644 --- a/packages/core/src/combobox/combobox-content.tsx +++ b/packages/core/src/combobox/combobox-content.tsx @@ -98,7 +98,7 @@ export function ComboboxContent(props: ComboboxContentProps) { createPreventScroll({ ownerRef: () => ref, - isDisabled: () => !(context.isOpen() && context.isModal()), + isDisabled: () => !(context.isOpen() && (context.isModal() || context.preventScroll())), }); createFocusScope( diff --git a/packages/core/src/combobox/combobox-context.tsx b/packages/core/src/combobox/combobox-context.tsx index 35055fa8..b9a5ade5 100644 --- a/packages/core/src/combobox/combobox-context.tsx +++ b/packages/core/src/combobox/combobox-context.tsx @@ -17,6 +17,7 @@ export interface ComboboxContextValue { isMultiple: Accessor; isVirtualized: Accessor; isModal: Accessor; + preventScroll: Accessor; isInputFocused: Accessor; allowsEmptyCollection: Accessor; shouldFocusWrap: Accessor; diff --git a/packages/core/src/dialog/dialog-content.tsx b/packages/core/src/dialog/dialog-content.tsx index 75cfc24d..00737073 100644 --- a/packages/core/src/dialog/dialog-content.tsx +++ b/packages/core/src/dialog/dialog-content.tsx @@ -176,7 +176,7 @@ export function DialogContent(props: DialogContentProps) { createPreventScroll({ ownerRef: () => ref, - isDisabled: () => !(context.isOpen() && context.modal()), + isDisabled: () => !(context.isOpen() && (context.modal() || context.preventScroll())), }); createFocusScope( diff --git a/packages/core/src/dialog/dialog-context.tsx b/packages/core/src/dialog/dialog-context.tsx index c9cf058c..00f026f0 100644 --- a/packages/core/src/dialog/dialog-context.tsx +++ b/packages/core/src/dialog/dialog-context.tsx @@ -5,6 +5,7 @@ import { CreatePresenceResult } from "../primitives"; export interface DialogContextValue { isOpen: Accessor; modal: Accessor; + preventScroll: Accessor; contentId: Accessor; titleId: Accessor; descriptionId: Accessor; diff --git a/packages/core/src/dialog/dialog-root.tsx b/packages/core/src/dialog/dialog-root.tsx index 54819b35..d432d0fb 100644 --- a/packages/core/src/dialog/dialog-root.tsx +++ b/packages/core/src/dialog/dialog-root.tsx @@ -34,6 +34,9 @@ export interface DialogRootOptions { */ modal?: boolean; + /** Whether the scroll should be locked even if the dialog is not modal. */ + preventScroll?: boolean; + /** * Used to force mounting the dialog (portal, overlay and content) when more control is needed. * Useful when controlling animation with SolidJS animation libraries. @@ -53,6 +56,7 @@ export function DialogRoot(props: DialogRootProps) { { id: defaultId, modal: true, + preventScroll: false, }, props ); @@ -76,7 +80,8 @@ export function DialogRoot(props: DialogRootProps) { const context: DialogContextValue = { isOpen: disclosureState.isOpen, - modal: () => props.modal!, + modal: () => props.modal ?? true, + preventScroll: () => props.preventScroll ?? false, contentId, titleId, descriptionId, diff --git a/packages/core/src/menu/menu-content.tsx b/packages/core/src/menu/menu-content.tsx index 3589c846..692e9496 100644 --- a/packages/core/src/menu/menu-content.tsx +++ b/packages/core/src/menu/menu-content.tsx @@ -20,7 +20,7 @@ export function MenuContent(props: MenuContentProps) { createPreventScroll({ ownerRef: () => ref, - isDisabled: () => !(context.isOpen() && rootContext.isModal()), + isDisabled: () => !(context.isOpen() && (rootContext.isModal() || rootContext.preventScroll())), }); return (ref = el), local.ref)} {...others} />; diff --git a/packages/core/src/menu/menu-root-context.tsx b/packages/core/src/menu/menu-root-context.tsx index 8f7bbf9c..b2dc23d6 100644 --- a/packages/core/src/menu/menu-root-context.tsx +++ b/packages/core/src/menu/menu-root-context.tsx @@ -2,6 +2,7 @@ import { Accessor, createContext, useContext } from "solid-js"; export interface MenuRootContextValue { isModal: Accessor; + preventScroll: Accessor; forceMount: Accessor; generateId: (part: string) => string; } diff --git a/packages/core/src/menu/menu-root.tsx b/packages/core/src/menu/menu-root.tsx index dad2f409..c3cf2d92 100644 --- a/packages/core/src/menu/menu-root.tsx +++ b/packages/core/src/menu/menu-root.tsx @@ -23,6 +23,9 @@ export interface MenuRootOptions extends MenuOptions { */ modal?: boolean; + /** Whether the scroll should be locked even if the menu is not modal. */ + preventScroll?: boolean; + /** * Used to force mounting the menu (portal, positioner and content) when more control is needed. * Useful when controlling animation with SolidJS animation libraries. @@ -43,6 +46,7 @@ export function MenuRoot(props: MenuRootProps) { { id: defaultId, modal: true, + preventScroll: false, }, props ); @@ -50,6 +54,7 @@ export function MenuRoot(props: MenuRootProps) { const [local, others] = splitProps(props, [ "id", "modal", + "preventScroll", "forceMount", "open", "defaultOpen", @@ -64,6 +69,7 @@ export function MenuRoot(props: MenuRootProps) { const context: MenuRootContextValue = { isModal: () => local.modal ?? true, + preventScroll: () => local.preventScroll ?? false, forceMount: () => local.forceMount ?? false, generateId: createGenerateId(() => local.id!), }; diff --git a/packages/core/src/popover/popover-content.tsx b/packages/core/src/popover/popover-content.tsx index 513621af..f0e0921a 100644 --- a/packages/core/src/popover/popover-content.tsx +++ b/packages/core/src/popover/popover-content.tsx @@ -169,18 +169,18 @@ export function PopoverContent(props: PopoverContentProps) { // aria-hide everything except the content (better supported equivalent to setting aria-modal) createHideOutside({ - isDisabled: () => !(context.isModal() && context.isOpen()), + isDisabled: () => !(context.isOpen() && context.isModal()), targets: () => (ref ? [ref] : []), }); createPreventScroll({ ownerRef: () => ref, - isDisabled: () => !(context.isModal() && context.isOpen()), + isDisabled: () => !(context.isOpen() && (context.isModal() || context.preventScroll())), }); createFocusScope( { - trapFocus: () => context.isModal() && context.isOpen(), + trapFocus: () => context.isOpen() && context.isModal(), onMountAutoFocus: local.onOpenAutoFocus, onUnmountAutoFocus: onCloseAutoFocus, }, @@ -200,7 +200,7 @@ export function PopoverContent(props: PopoverContentProps) { }, local.ref)} role="dialog" tabIndex={-1} - disableOutsidePointerEvents={context.isModal() && context.isOpen()} + disableOutsidePointerEvents={context.isOpen() && context.isModal()} excludedElements={[context.triggerRef]} style={{ "--kb-popover-content-transform-origin": "var(--kb-popper-content-transform-origin)", diff --git a/packages/core/src/popover/popover-context.tsx b/packages/core/src/popover/popover-context.tsx index 684c5eb6..7fdb4f47 100644 --- a/packages/core/src/popover/popover-context.tsx +++ b/packages/core/src/popover/popover-context.tsx @@ -11,6 +11,7 @@ export interface PopoverContextValue { dataset: Accessor; isOpen: Accessor; isModal: Accessor; + preventScroll: Accessor; contentPresence: CreatePresenceResult; triggerRef: Accessor; contentId: Accessor; diff --git a/packages/core/src/popover/popover-root.tsx b/packages/core/src/popover/popover-root.tsx index 730fd4d0..091afd64 100644 --- a/packages/core/src/popover/popover-root.tsx +++ b/packages/core/src/popover/popover-root.tsx @@ -57,6 +57,9 @@ export interface PopoverRootOptions */ modal?: boolean; + /** Whether the scroll should be locked even if the popover is not modal. */ + preventScroll?: boolean; + /** * Used to force mounting the popover (portal, positioner and content) when more control is needed. * Useful when controlling animation with SolidJS animation libraries. @@ -76,6 +79,7 @@ export function PopoverRoot(props: PopoverRootProps) { { id: defaultId, modal: false, + preventScroll: false, }, props ); @@ -86,6 +90,7 @@ export function PopoverRoot(props: PopoverRootProps) { "defaultOpen", "onOpenChange", "modal", + "preventScroll", "forceMount", "anchorRef", ]); @@ -118,7 +123,8 @@ export function PopoverRoot(props: PopoverRootProps) { const context: PopoverContextValue = { dataset, isOpen: disclosureState.isOpen, - isModal: () => local.modal!, + isModal: () => local.modal ?? false, + preventScroll: () => local.preventScroll ?? false, contentPresence, triggerRef, contentId, diff --git a/packages/core/src/select/select-base.tsx b/packages/core/src/select/select-base.tsx index f2ed6f6f..880f791a 100644 --- a/packages/core/src/select/select-base.tsx +++ b/packages/core/src/select/select-base.tsx @@ -146,6 +146,9 @@ export interface SelectBaseOptions */ modal?: boolean; + /** Whether the scroll should be locked even if the select is not modal. */ + preventScroll?: boolean; + /** * Used to force mounting the select (portal, positioner and content) when more control is needed. * Useful when controlling animation with SolidJS animation libraries. @@ -201,6 +204,7 @@ export function SelectBase(props: SelectBaseProps(props: SelectBaseProps(props: SelectBaseProps access(local.selectionMode) === "multiple", isVirtualized: () => local.virtualized ?? false, isModal: () => local.modal ?? false, + preventScroll: () => local.preventScroll ?? false, disallowTypeAhead: () => local.disallowTypeAhead ?? false, shouldFocusWrap: () => local.shouldFocusWrap ?? false, selectedOptions, diff --git a/packages/core/src/select/select-content.tsx b/packages/core/src/select/select-content.tsx index 21b927a8..0a16c821 100644 --- a/packages/core/src/select/select-content.tsx +++ b/packages/core/src/select/select-content.tsx @@ -86,7 +86,7 @@ export function SelectContent(props: SelectContentProps) { createPreventScroll({ ownerRef: () => ref, - isDisabled: () => !(context.isOpen() && context.isModal()), + isDisabled: () => !(context.isOpen() && (context.isModal() || context.preventScroll())), }); createFocusScope( diff --git a/packages/core/src/select/select-context.tsx b/packages/core/src/select/select-context.tsx index 9182b297..18388d1d 100644 --- a/packages/core/src/select/select-context.tsx +++ b/packages/core/src/select/select-context.tsx @@ -16,6 +16,7 @@ export interface SelectContextValue { isMultiple: Accessor; isVirtualized: Accessor; isModal: Accessor; + preventScroll: Accessor; disallowTypeAhead: Accessor; shouldFocusWrap: Accessor; selectedOptions: Accessor; From 03af9592f2e361eee59ebf7d889683bed94ae378 Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Thu, 4 May 2023 11:00:25 +0200 Subject: [PATCH 4/7] feat(checkbox): #207 --- apps/docs/src/examples/checkbox.module.css | 27 +++ apps/docs/src/examples/checkbox.tsx | 46 ++++ .../routes/docs/core/components/checkbox.mdx | 93 +++++++- .../docs/core/components/text-field.mdx | 20 +- .../core/src/checkbox/checkbox-context.tsx | 11 +- .../core/src/checkbox/checkbox-control.tsx | 32 ++- .../src/checkbox/checkbox-description.tsx | 13 ++ .../src/checkbox/checkbox-error-message.tsx | 13 ++ .../core/src/checkbox/checkbox-indicator.tsx | 3 + packages/core/src/checkbox/checkbox-input.tsx | 62 +++--- packages/core/src/checkbox/checkbox-label.tsx | 15 +- packages/core/src/checkbox/checkbox-root.tsx | 96 +++++---- packages/core/src/checkbox/checkbox.test.tsx | 202 +++++++++++++++--- packages/core/src/checkbox/index.tsx | 12 +- .../core/src/text-field/text-field-root.tsx | 18 +- .../core/src/text-field/text-field.test.tsx | 2 +- 16 files changed, 508 insertions(+), 157 deletions(-) create mode 100644 packages/core/src/checkbox/checkbox-description.tsx create mode 100644 packages/core/src/checkbox/checkbox-error-message.tsx diff --git a/apps/docs/src/examples/checkbox.module.css b/apps/docs/src/examples/checkbox.module.css index cd9d0399..ce76e164 100644 --- a/apps/docs/src/examples/checkbox.module.css +++ b/apps/docs/src/examples/checkbox.module.css @@ -22,6 +22,10 @@ color: white; } +.checkbox__control[data-invalid] { + border-color: hsl(0 72% 51%); +} + .checkbox__label { margin-left: 6px; color: hsl(240 6% 10%); @@ -29,6 +33,20 @@ user-select: none; } +.checkbox__description { + margin-left: 6px; + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; +} + +.checkbox__error-message { + margin-left: 6px; + color: hsl(0 72% 51%); + font-size: 12px; + user-select: none; +} + [data-kb-theme="dark"] .checkbox__control { border-color: hsl(240 5% 34%); background-color: hsl(240 5% 26%); @@ -40,6 +58,15 @@ color: hsl(0 100% 100% / 0.9); } +[data-kb-theme="dark"] .checkbox__control[data-invalid] { + border-color: hsl(0 72% 51%); +} + [data-kb-theme="dark"] .checkbox__label { color: hsl(240 5% 84%); } + +[data-kb-theme="dark"] .checkbox__description { + color: hsl(240 5% 65%); +} + diff --git a/apps/docs/src/examples/checkbox.tsx b/apps/docs/src/examples/checkbox.tsx index 3675a8de..deca8ca0 100644 --- a/apps/docs/src/examples/checkbox.tsx +++ b/apps/docs/src/examples/checkbox.tsx @@ -1,4 +1,5 @@ import { Checkbox } from "@kobalte/core"; +import { clsx } from "clsx"; import { createSignal } from "solid-js"; import { CheckIcon } from "../components"; @@ -51,6 +52,51 @@ export function ControlledExample() { ); } +export function DescriptionExample() { + return ( + + + + + + + +
+ Subscribe + + You will receive our weekly newsletter. + +
+
+ ); +} + +export function ErrorMessageExample() { + const [checked, setChecked] = createSignal(false); + + return ( + + + + + + + +
+ Subscribe + + You must agree to our Terms and Conditions. + +
+
+ ); +} + export function HTMLFormExample() { let formRef: HTMLFormElement | undefined; diff --git a/apps/docs/src/routes/docs/core/components/checkbox.mdx b/apps/docs/src/routes/docs/core/components/checkbox.mdx index 25722f97..0b51b0fb 100644 --- a/apps/docs/src/routes/docs/core/components/checkbox.mdx +++ b/apps/docs/src/routes/docs/core/components/checkbox.mdx @@ -3,6 +3,8 @@ import { BasicExample, ControlledExample, DefaultCheckedExample, + DescriptionExample, + ErrorMessageExample, HTMLFormExample, } from "../../../../examples/checkbox"; @@ -21,6 +23,7 @@ import { Checkbox } from "@kobalte/core"; - Built with a native HTML `` element, which is visually hidden to allow custom styling. - Syncs with form reset events. - Labeling support for assistive technology. +- Support for description and error message help text linked to the input via ARIA. - Can be controlled or uncontrolled. ## Anatomy @@ -32,6 +35,8 @@ The checkbox consists of: - **Checkbox.Control:** The element that visually represents a checkbox. - **Checkbox.Indicator:** The visual indicator rendered when the checkbox is in a checked or indeterminate state. - **Checkbox.Label:** The label that gives the user information on the checkbox. +- **Checkbox.Description**: The description that gives the user more information on the checkbox. +- **Checkbox.ErrorMessage**: The error message that gives the user information about how to fix a validation error on the checkbox. ```tsx @@ -40,6 +45,8 @@ The checkbox consists of: + + ``` @@ -154,6 +161,62 @@ function ControlledExample() { } ``` +### Description + +The `Checkbox.Description` component can be used to associate additional help text with a checkbox. + + + + + +```tsx {8} + + + + + + + + Subscribe + You will receive our weekly newsletter. + +``` + +### Error message + +The `Checkbox.ErrorMessage` component can be used to help the user fix a validation error. It should be combined with the `validationState` prop to semantically mark the checkbox as invalid for assistive technologies. + +By default, it will render only when the `validationState` prop is set to `invalid`, use the `forceMount` prop to always render the error message (ex: for usage with animation libraries). + + + + + +```tsx {9,17} +import { createSignal } from "solid-js"; + +function ErrorMessageExample() { + const [checked, setChecked] = createSignal(false); + + return ( + + + + + + + + Agree + You must agree to our Terms and Conditions. + + ); +} +``` + ### HTML forms The `name` and `value` props can be used for integration with HTML forms. @@ -192,8 +255,8 @@ function HTMLFormExample() { | defaultChecked | `boolean`
The default checked state when initially rendered. Useful when you do not need to control the checked state. | | onChange | `(checked: boolean) => void`
Event handler called when the checked state of the checkbox changes. | | indeterminate | `boolean`
Whether the checkbox is in an indeterminate state. | -| name | `string`
The name of the checkbox, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). | | value | `string`
The value of the checkbox, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefvalue). | +| name | `string`
The name of the checkbox, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). | | validationState | `'valid' \| 'invalid'`
Whether the checkbox should display its "valid" or "invalid" visual styling. | | required | `boolean`
Whether the user must check the checkbox before the owning form can be submitted. | | disabled | `boolean`
Whether the checkbox is disabled. | @@ -209,13 +272,13 @@ function HTMLFormExample() { | :----------------- | :---------------------------------------------------------------------- | | data-valid | Present when the checkbox is valid according to the validation rules. | | data-invalid | Present when the checkbox is invalid according to the validation rules. | -| data-checked | Present when the checkbox is checked. | -| data-indeterminate | Present when the checkbox is in an indeterminate state. | | data-required | Present when the checkbox is required. | | data-disabled | Present when the checkbox is disabled. | | data-readonly | Present when the checkbox is read only. | +| data-checked | Present when the checkbox is checked. | +| data-indeterminate | Present when the checkbox is in an indeterminate state. | -`Checkbox.Input`, `Checkbox.Control`, `Checkbox.Indicator` and `Checkbox.Label` share the same data-attributes. +`Checkbox.Input`, `Checkbox.Control`, `Checkbox.Indicator`, `Checkbox.Label`, `Checkbox.Description` and `Checkbox.ErrorMessage` share the same data-attributes. ### Checkbox.Indicator @@ -223,15 +286,23 @@ function HTMLFormExample() { | :--------- | :-------------------------------------------------------------------------------------------------------------------------------------- | | forceMount | `boolean`
Used to force mounting when more control is needed. Useful when controlling animation with SolidJS animation libraries. | +### Checkbox.ErrorMessage + +| Prop | Description | +| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| forceMount | `boolean`
Used to force mounting when more control is needed. Useful when controlling animation with SolidJS animation libraries. | + ## Rendered elements -| Component | Default rendered element | -| :------------------- | :----------------------- | -| `Checkbox.Root` | `label` | -| `Checkbox.Input` | `input` | -| `Checkbox.Control` | `div` | -| `Checkbox.Indicator` | `div` | -| `Checkbox.Label` | `span` | +| Component | Default rendered element | +| :---------------------- | :----------------------- | +| `Checkbox.Root` | `div` | +| `Checkbox.Input` | `input` | +| `Checkbox.Control` | `div` | +| `Checkbox.Indicator` | `div` | +| `Checkbox.Label` | `label` | +| `Checkbox.Description` | `div` | +| `Checkbox.ErrorMessage` | `div` | ## Accessibility diff --git a/apps/docs/src/routes/docs/core/components/text-field.mdx b/apps/docs/src/routes/docs/core/components/text-field.mdx index ebc25272..dc2d93b0 100644 --- a/apps/docs/src/routes/docs/core/components/text-field.mdx +++ b/apps/docs/src/routes/docs/core/components/text-field.mdx @@ -290,16 +290,16 @@ function HTMLFormExample() { ### TextField.Root -| Prop | Description | -| :-------------- | :---------------------------------------------------------------------------------------------------------- | -| value | `string`
The controlled value of the text field to check. | -| defaultValue | `string`
The default value when initially rendered. Useful when you do not need to control the value. | -| onChange | `(value: string) => void`
Event handler called when the value of the textfield changes. | -| name | `string`
The name of the text field. Submitted with its owning form as part of a name/value pair. | -| validationState | `'valid' \| 'invalid'`
Whether the text field should display its "valid" or "invalid" visual styling. | -| required | `boolean`
Whether the user must fill the text field before the owning form can be submitted. | -| disabled | `boolean`
Whether the text field is disabled. | -| readOnly | `boolean`
Whether the text field items can be selected but not changed by the user. | +| Prop | Description | +| :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| value | `string`
The controlled value of the text field to check. | +| defaultValue | `string`
The default value when initially rendered. Useful when you do not need to control the value. | +| onChange | `(value: string) => void`
Event handler called when the value of the textfield changes. | +| name | `string`
The name of the text field, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). | +| validationState | `'valid' \| 'invalid'`
Whether the text field should display its "valid" or "invalid" visual styling. | +| required | `boolean`
Whether the user must fill the text field before the owning form can be submitted. | +| disabled | `boolean`
Whether the text field is disabled. | +| readOnly | `boolean`
Whether the text field items can be selected but not changed by the user. | | Data attribute | Description | | :------------- | :-------------------------------------------------------------------------------------- | diff --git a/packages/core/src/checkbox/checkbox-context.tsx b/packages/core/src/checkbox/checkbox-context.tsx index eae5c8d9..9d68643f 100644 --- a/packages/core/src/checkbox/checkbox-context.tsx +++ b/packages/core/src/checkbox/checkbox-context.tsx @@ -1,27 +1,18 @@ -import { ValidationState } from "@kobalte/utils"; import { Accessor, createContext, useContext } from "solid-js"; export interface CheckboxDataSet { - "data-valid": string | undefined; - "data-invalid": string | undefined; "data-checked": string | undefined; "data-indeterminate": string | undefined; - "data-required": string | undefined; - "data-disabled": string | undefined; - "data-readonly": string | undefined; } export interface CheckboxContextValue { name: Accessor; value: Accessor; dataset: Accessor; - validationState: Accessor; checked: Accessor; - required: Accessor; - disabled: Accessor; - readOnly: Accessor; indeterminate: Accessor; generateId: (part: string) => string; + toggle: () => void; setIsChecked: (isChecked: boolean) => void; setIsFocused: (isFocused: boolean) => void; } diff --git a/packages/core/src/checkbox/checkbox-control.tsx b/packages/core/src/checkbox/checkbox-control.tsx index ef8ab76b..80feab0d 100644 --- a/packages/core/src/checkbox/checkbox-control.tsx +++ b/packages/core/src/checkbox/checkbox-control.tsx @@ -1,5 +1,7 @@ -import { mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; +import { callHandler, EventKey, mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; +import { JSX, splitProps } from "solid-js"; +import { useFormControlContext } from "../form-control"; import { AsChildProp, Polymorphic } from "../polymorphic"; import { useCheckboxContext } from "./checkbox-context"; @@ -9,6 +11,7 @@ export interface CheckboxControlProps extends OverrideComponentProps<"div", AsCh * The element that visually represents a checkbox. */ export function CheckboxControl(props: CheckboxControlProps) { + const formControlContext = useFormControlContext(); const context = useCheckboxContext(); props = mergeDefaultProps( @@ -18,5 +21,30 @@ export function CheckboxControl(props: CheckboxControlProps) { props ); - return ; + const [local, others] = splitProps(props, ["onClick", "onKeyDown"]); + + const onClick: JSX.EventHandlerUnion = e => { + callHandler(e, local.onClick); + + context.toggle(); + }; + + const onKeyDown: JSX.EventHandlerUnion = e => { + callHandler(e, local.onKeyDown); + + if (e.key === EventKey.Space) { + context.toggle(); + } + }; + + return ( + + ); } diff --git a/packages/core/src/checkbox/checkbox-description.tsx b/packages/core/src/checkbox/checkbox-description.tsx new file mode 100644 index 00000000..d010f3b1 --- /dev/null +++ b/packages/core/src/checkbox/checkbox-description.tsx @@ -0,0 +1,13 @@ +import { FormControlDescription, FormControlDescriptionProps } from "../form-control"; +import { useCheckboxContext } from "./checkbox-context"; + +export interface CheckboxDescriptionProps extends FormControlDescriptionProps {} + +/** + * The description that gives the user more information on the checkbox. + */ +export function CheckboxDescription(props: CheckboxDescriptionProps) { + const context = useCheckboxContext(); + + return ; +} diff --git a/packages/core/src/checkbox/checkbox-error-message.tsx b/packages/core/src/checkbox/checkbox-error-message.tsx new file mode 100644 index 00000000..b49514d6 --- /dev/null +++ b/packages/core/src/checkbox/checkbox-error-message.tsx @@ -0,0 +1,13 @@ +import { FormControlErrorMessage, FormControlErrorMessageProps } from "../form-control"; +import { useCheckboxContext } from "./checkbox-context"; + +export interface CheckboxErrorMessageProps extends FormControlErrorMessageProps {} + +/** + * The error message that gives the user information about how to fix a validation error on the checkbox. + */ +export function CheckboxErrorMessage(props: CheckboxErrorMessageProps) { + const context = useCheckboxContext(); + + return ; +} diff --git a/packages/core/src/checkbox/checkbox-indicator.tsx b/packages/core/src/checkbox/checkbox-indicator.tsx index 0da85af3..d2d6e711 100644 --- a/packages/core/src/checkbox/checkbox-indicator.tsx +++ b/packages/core/src/checkbox/checkbox-indicator.tsx @@ -1,6 +1,7 @@ import { mergeDefaultProps, mergeRefs, OverrideComponentProps } from "@kobalte/utils"; import { Show, splitProps } from "solid-js"; +import { useFormControlContext } from "../form-control"; import { AsChildProp, Polymorphic } from "../polymorphic"; import { createPresence } from "../primitives"; import { useCheckboxContext } from "./checkbox-context"; @@ -21,6 +22,7 @@ export interface CheckboxIndicatorProps * You can style this element directly, or you can use it as a wrapper to put an icon into, or both. */ export function CheckboxIndicator(props: CheckboxIndicatorProps) { + const formControlContext = useFormControlContext(); const context = useCheckboxContext(); props = mergeDefaultProps( @@ -41,6 +43,7 @@ export function CheckboxIndicator(props: CheckboxIndicatorProps) { diff --git a/packages/core/src/checkbox/checkbox-input.tsx b/packages/core/src/checkbox/checkbox-input.tsx index 098e8a94..6a7b9eaa 100644 --- a/packages/core/src/checkbox/checkbox-input.tsx +++ b/packages/core/src/checkbox/checkbox-input.tsx @@ -16,6 +16,11 @@ import { } from "@kobalte/utils"; import { createEffect, JSX, on, splitProps } from "solid-js"; +import { + createFormControlField, + FORM_CONTROL_FIELD_PROP_NAMES, + useFormControlContext, +} from "../form-control"; import { useCheckboxContext } from "./checkbox-context"; export interface CheckboxInputOptions { @@ -31,30 +36,23 @@ export interface CheckboxInputProps extends OverrideComponentProps<"input", Chec export function CheckboxInput(props: CheckboxInputProps) { let ref: HTMLInputElement | undefined; + const formControlContext = useFormControlContext(); const context = useCheckboxContext(); - props = mergeDefaultProps({ id: context.generateId("input") }, props); - - const [local, others] = splitProps(props, [ - "ref", - "style", - "aria-labelledby", - "onChange", - "onFocus", - "onBlur", - ]); - - const ariaLabelledBy = () => { - return ( - [ - local["aria-labelledby"], - // If there is both an aria-label and aria-labelledby, add the input itself has an aria-labelledby - local["aria-labelledby"] != null && others["aria-label"] != null ? others.id : undefined, - ] - .filter(Boolean) - .join(" ") || undefined - ); - }; + props = mergeDefaultProps( + { + id: context.generateId("input"), + }, + props + ); + + const [local, formControlFieldProps, others] = splitProps( + props, + ["ref", "style", "onChange", "onFocus", "onBlur"], + FORM_CONTROL_FIELD_PROP_NAMES + ); + + const { fieldProps } = createFormControlField(formControlFieldProps); const onChange: JSX.ChangeEventHandlerUnion = e => { callHandler(e, local.onChange); @@ -102,21 +100,25 @@ export function CheckboxInput(props: CheckboxInputProps) { (ref = el), local.ref)} type="checkbox" + id={fieldProps.id()} name={context.name()} value={context.value()} checked={context.checked()} - required={context.required()} - disabled={context.disabled()} - readonly={context.readOnly()} + required={formControlContext.isRequired()} + disabled={formControlContext.isDisabled()} + readonly={formControlContext.isReadOnly()} style={{ ...visuallyHiddenStyles, ...local.style }} - aria-labelledby={ariaLabelledBy()} - aria-invalid={context.validationState() === "invalid" || undefined} - aria-required={context.required() || undefined} - aria-disabled={context.disabled() || undefined} - aria-readonly={context.readOnly() || undefined} + aria-label={fieldProps.ariaLabel()} + aria-labelledby={fieldProps.ariaLabelledBy()} + aria-describedby={fieldProps.ariaDescribedBy()} + aria-invalid={formControlContext.validationState() === "invalid" || undefined} + aria-required={formControlContext.isRequired() || undefined} + aria-disabled={formControlContext.isDisabled() || undefined} + aria-readonly={formControlContext.isReadOnly() || undefined} onChange={onChange} onFocus={onFocus} onBlur={onBlur} + {...formControlContext.dataset()} {...context.dataset()} {...others} /> diff --git a/packages/core/src/checkbox/checkbox-label.tsx b/packages/core/src/checkbox/checkbox-label.tsx index b383d242..db896c74 100644 --- a/packages/core/src/checkbox/checkbox-label.tsx +++ b/packages/core/src/checkbox/checkbox-label.tsx @@ -1,9 +1,7 @@ -import { mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; - -import { AsChildProp, Polymorphic } from "../polymorphic"; +import { FormControlLabel, FormControlLabelProps } from "../form-control"; import { useCheckboxContext } from "./checkbox-context"; -export interface CheckboxLabelProps extends OverrideComponentProps<"span", AsChildProp> {} +export interface CheckboxLabelProps extends FormControlLabelProps {} /** * The label that gives the user information on the checkbox. @@ -11,12 +9,5 @@ export interface CheckboxLabelProps extends OverrideComponentProps<"span", AsChi export function CheckboxLabel(props: CheckboxLabelProps) { const context = useCheckboxContext(); - props = mergeDefaultProps( - { - id: context.generateId("label"), - }, - props - ); - - return ; + return ; } diff --git a/packages/core/src/checkbox/checkbox-root.tsx b/packages/core/src/checkbox/checkbox-root.tsx index c261772b..a83d0577 100644 --- a/packages/core/src/checkbox/checkbox-root.tsx +++ b/packages/core/src/checkbox/checkbox-root.tsx @@ -7,6 +7,7 @@ */ import { + access, callHandler, createGenerateId, isFunction, @@ -25,6 +26,8 @@ import { splitProps, } from "solid-js"; +import { createFormControl, FORM_CONTROL_PROP_NAMES, FormControlContext } from "../form-control"; +import { Polymorphic } from "../polymorphic"; import { createFormResetListener, createToggleState } from "../primitives"; import { CheckboxContext, CheckboxContextValue, CheckboxDataSet } from "./checkbox-context"; @@ -56,18 +59,18 @@ export interface CheckboxRootOptions { */ indeterminate?: boolean; - /** - * The name of the checkbox, used when submitting an HTML form. - * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). - */ - name?: string; - /** * The value of the checkbox, used when submitting an HTML form. * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefvalue). */ value?: string; + /** + * The name of the checkbox, used when submitting an HTML form. + * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). + */ + name?: string; + /** Whether the checkbox should display its "valid" or "invalid" visual styling. */ validationState?: ValidationState; @@ -87,13 +90,13 @@ export interface CheckboxRootOptions { children?: JSX.Element | ((state: CheckboxRootState) => JSX.Element); } -export interface CheckboxRootProps extends OverrideComponentProps<"label", CheckboxRootOptions> {} +export interface CheckboxRootProps extends OverrideComponentProps<"div", CheckboxRootOptions> {} /** * A control that allows the user to toggle between checked and not checked. */ export function CheckboxRoot(props: CheckboxRootProps) { - let ref: HTMLLabelElement | undefined; + let ref: HTMLDivElement | undefined; const defaultId = `checkbox-${createUniqueId()}`; @@ -105,31 +108,32 @@ export function CheckboxRoot(props: CheckboxRootProps) { props ); - const [local, others] = splitProps(props, [ - "ref", - "children", - "value", - "checked", - "defaultChecked", - "onChange", - "name", - "value", - "validationState", - "required", - "disabled", - "readOnly", - "indeterminate", - "onPointerDown", - ]); + const [local, formControlProps, others] = splitProps( + props, + [ + "ref", + "children", + "value", + "checked", + "defaultChecked", + "onChange", + "value", + "indeterminate", + "onPointerDown", + ], + FORM_CONTROL_PROP_NAMES + ); const [isFocused, setIsFocused] = createSignal(false); + const { formControlContext } = createFormControl(formControlProps); + const state = createToggleState({ isSelected: () => local.checked, defaultIsSelected: () => local.defaultChecked, onSelectedChange: selected => local.onChange?.(selected), - isDisabled: () => local.disabled, - isReadOnly: () => local.readOnly, + isDisabled: () => formControlContext.isDisabled(), + isReadOnly: () => formControlContext.isReadOnly(), }); createFormResetListener( @@ -147,41 +151,39 @@ export function CheckboxRoot(props: CheckboxRootProps) { }; const dataset: Accessor = createMemo(() => ({ - "data-valid": local.validationState === "valid" ? "" : undefined, - "data-invalid": local.validationState === "invalid" ? "" : undefined, "data-checked": state.isSelected() ? "" : undefined, "data-indeterminate": local.indeterminate ? "" : undefined, - "data-required": local.required ? "" : undefined, - "data-disabled": local.disabled ? "" : undefined, - "data-readonly": local.readOnly ? "" : undefined, })); const context: CheckboxContextValue = { - name: () => local.name ?? others.id!, + name: () => formControlContext.name(), value: () => local.value!, dataset, - validationState: () => local.validationState, checked: () => state.isSelected(), - required: () => local.required ?? false, - disabled: () => local.disabled ?? false, - readOnly: () => local.readOnly ?? false, indeterminate: () => local.indeterminate ?? false, - generateId: createGenerateId(() => others.id!), + generateId: createGenerateId(() => access(formControlProps.id)!), + toggle: () => state.toggle(), setIsChecked: isChecked => state.setIsSelected(isChecked), setIsFocused, }; return ( - - - + + + (ref = el), local.ref)} + role="group" + id={access(formControlProps.id)} + onPointerDown={onPointerDown} + {...formControlContext.dataset()} + {...dataset()} + {...others} + > + + + + ); } diff --git a/packages/core/src/checkbox/checkbox.test.tsx b/packages/core/src/checkbox/checkbox.test.tsx index 4f0a5e1d..0aa9457c 100644 --- a/packages/core/src/checkbox/checkbox.test.tsx +++ b/packages/core/src/checkbox/checkbox.test.tsx @@ -139,7 +139,6 @@ describe("Checkbox", () => { const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeFalsy(); expect(onChangeSpy).not.toHaveBeenCalled(); @@ -164,7 +163,6 @@ describe("Checkbox", () => { const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeTruthy(); fireEvent.click(input); @@ -183,7 +181,6 @@ describe("Checkbox", () => { const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeTruthy(); fireEvent.click(input); @@ -202,7 +199,6 @@ describe("Checkbox", () => { const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeFalsy(); fireEvent.click(input); @@ -221,7 +217,6 @@ describe("Checkbox", () => { const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.indeterminate).toBeTruthy(); expect(input.checked).toBeFalsy(); @@ -241,6 +236,47 @@ describe("Checkbox", () => { expect(onChangeSpy.mock.calls[1][0]).toBe(false); }); + it("can be checked by clicking on the control", async () => { + render(() => ( + + + + + )); + + const input = screen.getByRole("checkbox") as HTMLInputElement; + const control = screen.getByTestId("control"); + + expect(input.checked).toBeFalsy(); + + fireEvent.click(control); + await Promise.resolve(); + + expect(input.checked).toBeTruthy(); + expect(onChangeSpy.mock.calls[0][0]).toBe(true); + }); + + it("can be checked by pressing the Space key on the control", async () => { + render(() => ( + + + + + )); + + const input = screen.getByRole("checkbox") as HTMLInputElement; + const control = screen.getByTestId("control"); + + expect(input.checked).toBeFalsy(); + + fireEvent.keyDown(control, { key: " " }); + fireEvent.keyUp(control, { key: " " }); + await Promise.resolve(); + + expect(input.checked).toBeTruthy(); + expect(onChangeSpy.mock.calls[0][0]).toBe(true); + }); + it("can be disabled", async () => { render(() => ( @@ -252,7 +288,6 @@ describe("Checkbox", () => { const label = screen.getByTestId("label"); const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.disabled).toBeTruthy(); expect(input.checked).toBeFalsy(); @@ -273,7 +308,6 @@ describe("Checkbox", () => { const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input).toHaveAttribute("aria-invalid", "true"); }); @@ -286,26 +320,27 @@ describe("Checkbox", () => { const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input).toHaveAttribute("aria-invalid", "true"); expect(input).toHaveAttribute("aria-errormessage", "test"); }); - it("supports 'aria-label'", () => { + it("supports visible label", async () => { render(() => ( - - + Label + )); const input = screen.getByRole("checkbox") as HTMLInputElement; + const label = screen.getByText("Label"); - expect(input.value).toBe("on"); - expect(input).toHaveAttribute("aria-label", "Label"); + expect(input).toHaveAttribute("aria-labelledby", label.id); + expect(label).toBeInstanceOf(HTMLLabelElement); + expect(label).toHaveAttribute("for", input.id); }); - it("supports 'aria-labelledby'", () => { + it("supports 'aria-labelledby'", async () => { render(() => ( @@ -314,24 +349,69 @@ describe("Checkbox", () => { const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input).toHaveAttribute("aria-labelledby", "foo"); }); - it("should combine 'aria-label' and 'aria-labelledby'", () => { + it("should combine 'aria-labelledby' if visible label is also provided", async () => { render(() => ( - + Label + )); const input = screen.getByRole("checkbox") as HTMLInputElement; + const label = screen.getByText("Label"); - expect(input.value).toBe("on"); - expect(input).toHaveAttribute("aria-labelledby", `foo ${input.id}`); + expect(input).toHaveAttribute("aria-labelledby", `foo ${label.id}`); + }); + + it("supports 'aria-label'", async () => { + render(() => ( + + + + )); + + const input = screen.getByRole("checkbox") as HTMLInputElement; + + expect(input).toHaveAttribute("aria-label", "My Label"); + }); + + it("should combine 'aria-labelledby' if visible label and 'aria-label' is also provided", async () => { + render(() => ( + + Label + + + )); + + const input = screen.getByRole("checkbox") as HTMLInputElement; + const label = screen.getByText("Label"); + + expect(input).toHaveAttribute("aria-labelledby", `foo ${label.id} ${input.id}`); + }); + + it("supports visible description", async () => { + render(() => ( + + + Description + + )); + + const input = screen.getByRole("checkbox") as HTMLInputElement; + const description = screen.getByText("Description"); + + expect(description.id).toBeDefined(); + expect(input.id).toBeDefined(); + expect(input).toHaveAttribute("aria-describedby", description.id); + + // check that generated ids are unique + expect(description.id).not.toBe(input.id); }); - it("supports 'aria-describedby'", () => { + it("supports 'aria-describedby'", async () => { render(() => ( @@ -343,7 +423,83 @@ describe("Checkbox", () => { expect(input).toHaveAttribute("aria-describedby", "foo"); }); - it("can be read only", async () => { + it("should combine 'aria-describedby' if visible description", async () => { + render(() => ( + + + Description + + )); + + const input = screen.getByRole("checkbox") as HTMLInputElement; + const description = screen.getByText("Description"); + + expect(input).toHaveAttribute("aria-describedby", `${description.id} foo`); + }); + + it("supports visible error message when invalid", async () => { + render(() => ( + + + ErrorMessage + + )); + + const input = screen.getByRole("checkbox") as HTMLInputElement; + const errorMessage = screen.getByText("ErrorMessage"); + + expect(errorMessage.id).toBeDefined(); + expect(input.id).toBeDefined(); + expect(input).toHaveAttribute("aria-describedby", errorMessage.id); + + // check that generated ids are unique + expect(errorMessage.id).not.toBe(input.id); + }); + + it("should not be described by error message when not invalid", async () => { + render(() => ( + + + ErrorMessage + + )); + + const input = screen.getByRole("checkbox") as HTMLInputElement; + + expect(input).not.toHaveAttribute("aria-describedby"); + }); + + it("should combine 'aria-describedby' if visible error message when invalid", () => { + render(() => ( + + + ErrorMessage + + )); + + const input = screen.getByRole("checkbox") as HTMLInputElement; + const errorMessage = screen.getByText("ErrorMessage"); + + expect(input).toHaveAttribute("aria-describedby", `${errorMessage.id} foo`); + }); + + it("should combine 'aria-describedby' if visible description and error message when invalid", () => { + render(() => ( + + + Description + ErrorMessage + + )); + + const input = screen.getByRole("checkbox") as HTMLInputElement; + const description = screen.getByText("Description"); + const errorMessage = screen.getByText("ErrorMessage"); + + expect(input).toHaveAttribute("aria-describedby", `${description.id} ${errorMessage.id} foo`); + }); + + it("can be readonly", async () => { render(() => ( @@ -352,7 +508,6 @@ describe("Checkbox", () => { const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeTruthy(); expect(input).toHaveAttribute("aria-readonly", "true"); @@ -363,7 +518,7 @@ describe("Checkbox", () => { expect(onChangeSpy).not.toHaveBeenCalled(); }); - it("supports uncontrolled read only", async () => { + it("supports uncontrolled readonly", async () => { render(() => ( @@ -372,7 +527,6 @@ describe("Checkbox", () => { const input = screen.getByRole("checkbox") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeFalsy(); fireEvent.click(input); diff --git a/packages/core/src/checkbox/index.tsx b/packages/core/src/checkbox/index.tsx index 278fe169..f213019f 100644 --- a/packages/core/src/checkbox/index.tsx +++ b/packages/core/src/checkbox/index.tsx @@ -1,4 +1,12 @@ import { CheckboxControl as Control, type CheckboxControlProps } from "./checkbox-control"; +import { + CheckboxDescription as Description, + type CheckboxDescriptionProps, +} from "./checkbox-description"; +import { + CheckboxErrorMessage as ErrorMessage, + type CheckboxErrorMessageProps, +} from "./checkbox-error-message"; import { CheckboxIndicator as Indicator, type CheckboxIndicatorOptions, @@ -18,6 +26,8 @@ import { export type { CheckboxControlProps, + CheckboxDescriptionProps, + CheckboxErrorMessageProps, CheckboxIndicatorOptions, CheckboxIndicatorProps, CheckboxInputOptions, @@ -26,4 +36,4 @@ export type { CheckboxRootOptions, CheckboxRootProps, }; -export { Control, Indicator, Input, Label, Root }; +export { Control, Description, ErrorMessage, Indicator, Input, Label, Root }; diff --git a/packages/core/src/text-field/text-field-root.tsx b/packages/core/src/text-field/text-field-root.tsx index 79d34497..8d3b2cc2 100644 --- a/packages/core/src/text-field/text-field-root.tsx +++ b/packages/core/src/text-field/text-field-root.tsx @@ -14,7 +14,7 @@ import { createControllableSignal, createFormResetListener } from "../primitives import { TextFieldContext, TextFieldContextValue } from "./text-field-context"; export interface TextFieldRootOptions extends AsChildProp { - /** The controlled value of the textfield. */ + /** The controlled value of the text field. */ value?: string; /** @@ -23,7 +23,7 @@ export interface TextFieldRootOptions extends AsChildProp { */ defaultValue?: string; - /** Event handler called when the value of the textfield changes. */ + /** Event handler called when the value of the text field changes. */ onChange?: (value: string) => void; /** @@ -34,21 +34,21 @@ export interface TextFieldRootOptions extends AsChildProp { id?: string; /** - * The name of the textfield. - * Submitted with its owning form as part of a name/value pair. + * The name of the text field, used when submitting an HTML form. + * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). */ name?: string; - /** Whether the textfield should display its "valid" or "invalid" visual styling. */ + /** Whether the text field should display its "valid" or "invalid" visual styling. */ validationState?: ValidationState; - /** Whether the user must fill the textfield before the owning form can be submitted. */ + /** Whether the user must fill the text field before the owning form can be submitted. */ required?: boolean; - /** Whether the textfield is disabled. */ + /** Whether the text field is disabled. */ disabled?: boolean; - /** Whether the textfield is read only. */ + /** Whether the text field is read only. */ readOnly?: boolean; } @@ -96,7 +96,7 @@ export function TextFieldRoot(props: TextFieldRootProps) { // even if an input is controlled (ex: ``, // typing on the input will change its internal `value`. // - // To prevent this, we need to force the input `value` to be in sync with the textfield value state. + // To prevent this, we need to force the input `value` to be in sync with the text field value state. target.value = value() ?? ""; }; diff --git a/packages/core/src/text-field/text-field.test.tsx b/packages/core/src/text-field/text-field.test.tsx index a1bd43df..2c005a67 100644 --- a/packages/core/src/text-field/text-field.test.tsx +++ b/packages/core/src/text-field/text-field.test.tsx @@ -1,6 +1,6 @@ import { installPointerEvent } from "@kobalte/tests"; -import userEvent from "@testing-library/user-event"; import { render, screen } from "@solidjs/testing-library"; +import userEvent from "@testing-library/user-event"; import * as TextField from "."; From dc2e2214be18a5f2d4119d02e451a17c678c4d17 Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Thu, 4 May 2023 12:42:46 +0200 Subject: [PATCH 5/7] feat(switch): #207 --- apps/docs/src/examples/switch.module.css | 27 +++ apps/docs/src/examples/switch.tsx | 42 ++++ apps/docs/src/routes/docs/core.tsx | 5 +- .../routes/docs/core/components/checkbox.mdx | 2 +- .../routes/docs/core/components/switch.mdx | 89 +++++++- .../core/src/checkbox/checkbox-context.tsx | 1 - packages/core/src/checkbox/checkbox-input.tsx | 2 +- packages/core/src/checkbox/checkbox-root.tsx | 4 +- packages/core/src/switch/index.tsx | 12 +- packages/core/src/switch/switch-context.tsx | 14 +- packages/core/src/switch/switch-control.tsx | 32 ++- .../core/src/switch/switch-description.tsx | 13 ++ .../core/src/switch/switch-error-message.tsx | 13 ++ packages/core/src/switch/switch-input.tsx | 58 +++-- packages/core/src/switch/switch-label.tsx | 15 +- packages/core/src/switch/switch-root.tsx | 120 +++++++---- packages/core/src/switch/switch-thumb.tsx | 6 +- packages/core/src/switch/switch.test.tsx | 200 ++++++++++++++++-- 18 files changed, 510 insertions(+), 145 deletions(-) create mode 100644 packages/core/src/switch/switch-description.tsx create mode 100644 packages/core/src/switch/switch-error-message.tsx diff --git a/apps/docs/src/examples/switch.module.css b/apps/docs/src/examples/switch.module.css index 495ec0fb..a2ebdd7d 100644 --- a/apps/docs/src/examples/switch.module.css +++ b/apps/docs/src/examples/switch.module.css @@ -25,6 +25,10 @@ background-color: hsl(200 98% 39%); } +.switch__control[data-invalid] { + border-color: hsl(0 72% 51%); +} + .switch__thumb { height: 20px; width: 20px; @@ -44,6 +48,20 @@ user-select: none; } +.switch__description { + margin-right: 6px; + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; +} + +.switch__error-message { + margin-right: 6px; + color: hsl(0 72% 51%); + font-size: 12px; + user-select: none; +} + [data-kb-theme="dark"] .switch__control { border-color: hsl(240 5% 34%); background-color: hsl(240 5% 26%); @@ -54,6 +72,10 @@ background-color: hsl(200 98% 39%); } +[data-kb-theme="dark"] .switch__control[data-invalid] { + border-color: hsl(0 72% 51%); +} + [data-kb-theme="dark"] .switch__thumb { background-color: hsl(240 5% 84%); } @@ -61,3 +83,8 @@ [data-kb-theme="dark"] .switch__label { color: hsl(240 5% 84%); } + +[data-kb-theme="dark"] .switch__description { + color: hsl(240 5% 65%); +} + diff --git a/apps/docs/src/examples/switch.tsx b/apps/docs/src/examples/switch.tsx index 9c10e8c9..d9eb04a4 100644 --- a/apps/docs/src/examples/switch.tsx +++ b/apps/docs/src/examples/switch.tsx @@ -1,4 +1,5 @@ import { Switch } from "@kobalte/core"; +import { clsx } from "clsx"; import { createSignal } from "solid-js"; import style from "./switch.module.css"; @@ -44,6 +45,47 @@ export function ControlledExample() { ); } +export function DescriptionExample() { + return ( + +
+ Airplane mode + + Disable all network connections. + +
+ + + + +
+ ); +} + +export function ErrorMessageExample() { + const [checked, setChecked] = createSignal(false); + + return ( + +
+ Airplane mode + + You must enable airplane mode. + +
+ + + + +
+ ); +} + export function HTMLFormExample() { let formRef: HTMLFormElement | undefined; diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index 755a072b..9f672854 100644 --- a/apps/docs/src/routes/docs/core.tsx +++ b/apps/docs/src/routes/docs/core.tsx @@ -59,6 +59,7 @@ const CORE_NAV_SECTIONS: NavSection[] = [ { title: "Checkbox", href: "/docs/core/components/checkbox", + status: "updated", }, { title: "Collapsible", @@ -67,7 +68,6 @@ const CORE_NAV_SECTIONS: NavSection[] = [ { title: "Combobox", href: "/docs/core/components/combobox", - status: "new", }, { title: "Context Menu", @@ -108,7 +108,6 @@ const CORE_NAV_SECTIONS: NavSection[] = [ { title: "Select", href: "/docs/core/components/select", - status: "updated", }, { title: "Separator", @@ -117,6 +116,7 @@ const CORE_NAV_SECTIONS: NavSection[] = [ { title: "Switch", href: "/docs/core/components/switch", + status: "updated", }, { title: "Tabs", @@ -137,7 +137,6 @@ const CORE_NAV_SECTIONS: NavSection[] = [ { title: "Tooltip", href: "/docs/core/components/tooltip", - status: "new", }, { title: "I18nProvider", diff --git a/apps/docs/src/routes/docs/core/components/checkbox.mdx b/apps/docs/src/routes/docs/core/components/checkbox.mdx index 0b51b0fb..4d7d3fe9 100644 --- a/apps/docs/src/routes/docs/core/components/checkbox.mdx +++ b/apps/docs/src/routes/docs/core/components/checkbox.mdx @@ -255,8 +255,8 @@ function HTMLFormExample() { | defaultChecked | `boolean`
The default checked state when initially rendered. Useful when you do not need to control the checked state. | | onChange | `(checked: boolean) => void`
Event handler called when the checked state of the checkbox changes. | | indeterminate | `boolean`
Whether the checkbox is in an indeterminate state. | -| value | `string`
The value of the checkbox, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefvalue). | | name | `string`
The name of the checkbox, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). | +| value | `string`
The value of the checkbox, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefvalue). | | validationState | `'valid' \| 'invalid'`
Whether the checkbox should display its "valid" or "invalid" visual styling. | | required | `boolean`
Whether the user must check the checkbox before the owning form can be submitted. | | disabled | `boolean`
Whether the checkbox is disabled. | diff --git a/apps/docs/src/routes/docs/core/components/switch.mdx b/apps/docs/src/routes/docs/core/components/switch.mdx index 1895c59e..f53fd39b 100644 --- a/apps/docs/src/routes/docs/core/components/switch.mdx +++ b/apps/docs/src/routes/docs/core/components/switch.mdx @@ -3,6 +3,8 @@ import { BasicExample, ControlledExample, DefaultCheckedExample, + DescriptionExample, + ErrorMessageExample, HTMLFormExample, } from "../../../../examples/switch"; @@ -22,6 +24,7 @@ import { Switch } from "@kobalte/core"; - Built with a native HTML `` element, which is visually hidden to allow custom styling. - Syncs with form reset events. - Labeling support for assistive technology. +- Support for description and error message help text linked to the input via ARIA. - Can be controlled or uncontrolled. ## Anatomy @@ -33,10 +36,14 @@ The switch consists of: - **Switch.Control:** The element that visually represents a switch. - **Switch.Thumb:** The thumb that is used to visually indicate whether the switch is on or off. - **Switch.Label:** The label that gives the user information on the switch. +- **Switch.Description**: The description that gives the user more information on the switch. +- **Switch.ErrorMessage**: The error message that gives the user information about how to fix a validation error on the switch. ```tsx + + @@ -167,6 +174,58 @@ export function ControlledExample() { } ``` +### Description + +The `Switch.Description` component can be used to associate additional help text with a switch. + + + + + +```tsx {2} + + Airplane mode + Disable all network connections. + + + + + +``` + +### Error message + +The `Switch.ErrorMessage` component can be used to help the user fix a validation error. It should be combined with the `validationState` prop to semantically mark the switch as invalid for assistive technologies. + +By default, it will render only when the `validationState` prop is set to `invalid`, use the `forceMount` prop to always render the error message (ex: for usage with animation libraries). + + + + + +```tsx {9,12} +import { createSignal } from "solid-js"; + +function ErrorMessageExample() { + const [checked, setChecked] = createSignal(false); + + return ( + + Airplane mode + You must enable airplane mode. + + + + + + ); +} +``` + ### HTML forms The `name` and `value` props can be used for integration with HTML forms. @@ -210,26 +269,38 @@ function HTMLFormExample() { | disabled | `boolean`
Whether the switch is disabled. | | readOnly | `boolean`
Whether the switch can be checked but not changed by the user. | +| Render Prop | Description | +| :---------- | :-------------------------------------------------------------- | +| checked | `Accessor`
Whether the switch is checked or not. | + | Data attribute | Description | | :------------- | :-------------------------------------------------------------------- | | data-valid | Present when the switch is valid according to the validation rules. | | data-invalid | Present when the switch is invalid according to the validation rules. | -| data-checked | Present when the switch is checked. | | data-required | Present when the switch is required. | | data-disabled | Present when the switch is disabled. | | data-readonly | Present when the switch is read only. | +| data-checked | Present when the switch is checked. | + +`Switch.Input`, `Switch.Control`, `Switch.Thumb`, `Switch.Label`, `Switch.Description` and `Switch.ErrorMessage` shares the same data-attributes. + +### Switch.ErrorMessage -`Switch.Input`, `Switch.Control`, `Switch.Thumb` and `Switch.Label` shares the same data-attributes. +| Prop | Description | +| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| forceMount | `boolean`
Used to force mounting when more control is needed. Useful when controlling animation with SolidJS animation libraries. | ## Rendered elements -| Component | Default rendered element | -| :--------------- | :----------------------- | -| `Switch.Root` | `label` | -| `Switch.Input` | `input` | -| `Switch.Label` | `span` | -| `Switch.Control` | `div` | -| `Switch.Thumb` | `div` | +| Component | Default rendered element | +| :-------------------- | :----------------------- | +| `Switch.Root` | `div` | +| `Switch.Input` | `input` | +| `Switch.Control` | `div` | +| `Switch.Indicator` | `div` | +| `Switch.Label` | `label` | +| `Switch.Description` | `div` | +| `Switch.ErrorMessage` | `div` | ## Accessibility diff --git a/packages/core/src/checkbox/checkbox-context.tsx b/packages/core/src/checkbox/checkbox-context.tsx index 9d68643f..332587fe 100644 --- a/packages/core/src/checkbox/checkbox-context.tsx +++ b/packages/core/src/checkbox/checkbox-context.tsx @@ -6,7 +6,6 @@ export interface CheckboxDataSet { } export interface CheckboxContextValue { - name: Accessor; value: Accessor; dataset: Accessor; checked: Accessor; diff --git a/packages/core/src/checkbox/checkbox-input.tsx b/packages/core/src/checkbox/checkbox-input.tsx index 6a7b9eaa..0fd81bd4 100644 --- a/packages/core/src/checkbox/checkbox-input.tsx +++ b/packages/core/src/checkbox/checkbox-input.tsx @@ -101,7 +101,7 @@ export function CheckboxInput(props: CheckboxInputProps) { ref={mergeRefs(el => (ref = el), local.ref)} type="checkbox" id={fieldProps.id()} - name={context.name()} + name={formControlContext.name()} value={context.value()} checked={context.checked()} required={formControlContext.isRequired()} diff --git a/packages/core/src/checkbox/checkbox-root.tsx b/packages/core/src/checkbox/checkbox-root.tsx index a83d0577..0caebc80 100644 --- a/packages/core/src/checkbox/checkbox-root.tsx +++ b/packages/core/src/checkbox/checkbox-root.tsx @@ -116,9 +116,8 @@ export function CheckboxRoot(props: CheckboxRootProps) { "value", "checked", "defaultChecked", - "onChange", - "value", "indeterminate", + "onChange", "onPointerDown", ], FORM_CONTROL_PROP_NAMES @@ -156,7 +155,6 @@ export function CheckboxRoot(props: CheckboxRootProps) { })); const context: CheckboxContextValue = { - name: () => formControlContext.name(), value: () => local.value!, dataset, checked: () => state.isSelected(), diff --git a/packages/core/src/switch/index.tsx b/packages/core/src/switch/index.tsx index 672337c1..4df57c17 100644 --- a/packages/core/src/switch/index.tsx +++ b/packages/core/src/switch/index.tsx @@ -1,4 +1,12 @@ import { SwitchControl as Control, type SwitchControlProps } from "./switch-control"; +import { + SwitchDescription as Description, + type SwitchDescriptionProps, +} from "./switch-description"; +import { + SwitchErrorMessage as ErrorMessage, + type SwitchErrorMessageProps, +} from "./switch-error-message"; import { SwitchInput as Input, type SwitchInputOptions, @@ -10,6 +18,8 @@ import { SwitchThumb as Thumb, type SwitchThumbProps } from "./switch-thumb"; export type { SwitchControlProps, + SwitchDescriptionProps, + SwitchErrorMessageProps, SwitchInputOptions, SwitchInputProps, SwitchLabelProps, @@ -17,4 +27,4 @@ export type { SwitchRootProps, SwitchThumbProps, }; -export { Control, Input, Label, Root, Thumb }; +export { Control, Description, ErrorMessage, Input, Label, Root, Thumb }; diff --git a/packages/core/src/switch/switch-context.tsx b/packages/core/src/switch/switch-context.tsx index 864b65a4..2d4a6c0f 100644 --- a/packages/core/src/switch/switch-context.tsx +++ b/packages/core/src/switch/switch-context.tsx @@ -1,25 +1,15 @@ -import { ValidationState } from "@kobalte/utils"; import { Accessor, createContext, useContext } from "solid-js"; export interface SwitchDataSet { - "data-valid": string | undefined; - "data-invalid": string | undefined; "data-checked": string | undefined; - "data-required": string | undefined; - "data-disabled": string | undefined; - "data-readonly": string | undefined; } export interface SwitchContextValue { - name: Accessor; value: Accessor; dataset: Accessor; - validationState: Accessor; - isChecked: Accessor; - isRequired: Accessor; - isDisabled: Accessor; - isReadOnly: Accessor; + checked: Accessor; generateId: (part: string) => string; + toggle: () => void; setIsChecked: (isChecked: boolean) => void; setIsFocused: (isFocused: boolean) => void; } diff --git a/packages/core/src/switch/switch-control.tsx b/packages/core/src/switch/switch-control.tsx index dd4d6dc2..122d357a 100644 --- a/packages/core/src/switch/switch-control.tsx +++ b/packages/core/src/switch/switch-control.tsx @@ -1,5 +1,7 @@ -import { mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; +import { callHandler, EventKey, mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; +import { JSX, splitProps } from "solid-js"; +import { useFormControlContext } from "../form-control"; import { AsChildProp, Polymorphic } from "../polymorphic"; import { useSwitchContext } from "./switch-context"; @@ -9,6 +11,7 @@ export interface SwitchControlProps extends OverrideComponentProps<"div", AsChil * The element that visually represents a switch. */ export function SwitchControl(props: SwitchControlProps) { + const formControlContext = useFormControlContext(); const context = useSwitchContext(); props = mergeDefaultProps( @@ -18,5 +21,30 @@ export function SwitchControl(props: SwitchControlProps) { props ); - return ; + const [local, others] = splitProps(props, ["onClick", "onKeyDown"]); + + const onClick: JSX.EventHandlerUnion = e => { + callHandler(e, local.onClick); + + context.toggle(); + }; + + const onKeyDown: JSX.EventHandlerUnion = e => { + callHandler(e, local.onKeyDown); + + if (e.key === EventKey.Space) { + context.toggle(); + } + }; + + return ( + + ); } diff --git a/packages/core/src/switch/switch-description.tsx b/packages/core/src/switch/switch-description.tsx new file mode 100644 index 00000000..53fec0fb --- /dev/null +++ b/packages/core/src/switch/switch-description.tsx @@ -0,0 +1,13 @@ +import { FormControlDescription, FormControlDescriptionProps } from "../form-control"; +import { useSwitchContext } from "./switch-context"; + +export interface SwitchDescriptionProps extends FormControlDescriptionProps {} + +/** + * The description that gives the user more information on the switch. + */ +export function SwitchDescription(props: SwitchDescriptionProps) { + const context = useSwitchContext(); + + return ; +} diff --git a/packages/core/src/switch/switch-error-message.tsx b/packages/core/src/switch/switch-error-message.tsx new file mode 100644 index 00000000..7ba51494 --- /dev/null +++ b/packages/core/src/switch/switch-error-message.tsx @@ -0,0 +1,13 @@ +import { FormControlErrorMessage, FormControlErrorMessageProps } from "../form-control"; +import { useSwitchContext } from "./switch-context"; + +export interface SwitchErrorMessageProps extends FormControlErrorMessageProps {} + +/** + * The error message that gives the user information about how to fix a validation error on the switch. + */ +export function SwitchErrorMessage(props: SwitchErrorMessageProps) { + const context = useSwitchContext(); + + return ; +} diff --git a/packages/core/src/switch/switch-input.tsx b/packages/core/src/switch/switch-input.tsx index 75913356..73d08ebb 100644 --- a/packages/core/src/switch/switch-input.tsx +++ b/packages/core/src/switch/switch-input.tsx @@ -15,6 +15,11 @@ import { } from "@kobalte/utils"; import { JSX, splitProps } from "solid-js"; +import { + createFormControlField, + FORM_CONTROL_FIELD_PROP_NAMES, + useFormControlContext, +} from "../form-control"; import { useSwitchContext } from "./switch-context"; export interface SwitchInputOptions { @@ -28,29 +33,18 @@ export interface SwitchInputProps extends OverrideComponentProps<"input", Switch * The native html input that is visually hidden in the switch. */ export function SwitchInput(props: SwitchInputProps) { + const formControlContext = useFormControlContext(); const context = useSwitchContext(); props = mergeDefaultProps({ id: context.generateId("input") }, props); - const [local, others] = splitProps(props, [ - "style", - "aria-labelledby", - "onChange", - "onFocus", - "onBlur", - ]); - - const ariaLabelledBy = () => { - return ( - [ - local["aria-labelledby"], - // If there is both an aria-label and aria-labelledby, add the input itself has an aria-labelledby - local["aria-labelledby"] != null && others["aria-label"] != null ? others.id : undefined, - ] - .filter(Boolean) - .join(" ") || undefined - ); - }; + const [local, formControlFieldProps, others] = splitProps( + props, + ["style", "onChange", "onFocus", "onBlur"], + FORM_CONTROL_FIELD_PROP_NAMES + ); + + const { fieldProps } = createFormControlField(formControlFieldProps); const onChange: JSX.ChangeEventHandlerUnion = e => { callHandler(e, local.onChange); @@ -68,7 +62,7 @@ export function SwitchInput(props: SwitchInputProps) { // clicking on the input will change its internal `checked` state. // // To prevent this, we need to force the input `checked` state to be in sync with the toggle state. - target.checked = context.isChecked(); + target.checked = context.checked(); }; const onFocus: JSX.FocusEventHandlerUnion = e => { @@ -85,21 +79,25 @@ export function SwitchInput(props: SwitchInputProps) { diff --git a/packages/core/src/switch/switch-label.tsx b/packages/core/src/switch/switch-label.tsx index b0e082ab..13526029 100644 --- a/packages/core/src/switch/switch-label.tsx +++ b/packages/core/src/switch/switch-label.tsx @@ -1,9 +1,7 @@ -import { mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; - -import { AsChildProp, Polymorphic } from "../polymorphic"; +import { FormControlLabel, FormControlLabelProps } from "../form-control"; import { useSwitchContext } from "./switch-context"; -export interface SwitchLabelProps extends OverrideComponentProps<"span", AsChildProp> {} +export interface SwitchLabelProps extends FormControlLabelProps {} /** * The label that gives the user information on the switch. @@ -11,12 +9,5 @@ export interface SwitchLabelProps extends OverrideComponentProps<"span", AsChild export function SwitchLabel(props: SwitchLabelProps) { const context = useSwitchContext(); - props = mergeDefaultProps( - { - id: context.generateId("label"), - }, - props - ); - - return ; + return ; } diff --git a/packages/core/src/switch/switch-root.tsx b/packages/core/src/switch/switch-root.tsx index adcb88cd..0324db1a 100644 --- a/packages/core/src/switch/switch-root.tsx +++ b/packages/core/src/switch/switch-root.tsx @@ -7,18 +7,35 @@ */ import { + access, callHandler, createGenerateId, + isFunction, mergeDefaultProps, mergeRefs, OverrideComponentProps, ValidationState, } from "@kobalte/utils"; -import { Accessor, createMemo, createSignal, createUniqueId, JSX, splitProps } from "solid-js"; - +import { + Accessor, + children, + createMemo, + createSignal, + createUniqueId, + JSX, + splitProps, +} from "solid-js"; + +import { createFormControl, FORM_CONTROL_PROP_NAMES, FormControlContext } from "../form-control"; +import { Polymorphic } from "../polymorphic"; import { createFormResetListener, createToggleState } from "../primitives"; import { SwitchContext, SwitchContextValue, SwitchDataSet } from "./switch-context"; +interface SwitchRootState { + /** Whether the switch is checked or not. */ + checked: Accessor; +} + export interface SwitchRootOptions { /** The controlled checked state of the switch. */ checked?: boolean; @@ -32,18 +49,18 @@ export interface SwitchRootOptions { /** Event handler called when the checked state of the switch changes. */ onChange?: (isChecked: boolean) => void; - /** - * The name of the switch, used when submitting an HTML form. - * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). - */ - name?: string; - /** * The value of the switch, used when submitting an HTML form. * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefvalue). */ value?: string; + /** + * The name of the switch, used when submitting an HTML form. + * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). + */ + name?: string; + /** Whether the switch should display its "valid" or "invalid" visual styling. */ validationState?: ValidationState; @@ -55,15 +72,21 @@ export interface SwitchRootOptions { /** Whether the switch is read only. */ readOnly?: boolean; + + /** + * The children of the switch. + * Can be a `JSX.Element` or a _render prop_ for having access to the internal state. + */ + children?: JSX.Element | ((state: SwitchRootState) => JSX.Element); } -export interface SwitchRootProps extends OverrideComponentProps<"label", SwitchRootOptions> {} +export interface SwitchRootProps extends OverrideComponentProps<"div", SwitchRootOptions> {} /** * A control that allows users to choose one of two values: on or off. */ export function SwitchRoot(props: SwitchRootProps) { - let ref: HTMLLabelElement | undefined; + let ref: HTMLDivElement | undefined; const defaultId = `switch-${createUniqueId()}`; @@ -75,29 +98,22 @@ export function SwitchRoot(props: SwitchRootProps) { props ); - const [local, others] = splitProps(props, [ - "ref", - "value", - "checked", - "defaultChecked", - "onChange", - "name", - "value", - "validationState", - "required", - "disabled", - "readOnly", - "onPointerDown", - ]); + const [local, formControlProps, others] = splitProps( + props, + ["ref", "children", "value", "checked", "defaultChecked", "onChange", "onPointerDown"], + FORM_CONTROL_PROP_NAMES + ); const [isFocused, setIsFocused] = createSignal(false); + const { formControlContext } = createFormControl(formControlProps); + const state = createToggleState({ isSelected: () => local.checked, defaultIsSelected: () => local.defaultChecked, onSelectedChange: selected => local.onChange?.(selected), - isDisabled: () => local.disabled, - isReadOnly: () => local.readOnly, + isDisabled: () => formControlContext.isDisabled(), + isReadOnly: () => formControlContext.isReadOnly(), }); createFormResetListener( @@ -115,36 +131,48 @@ export function SwitchRoot(props: SwitchRootProps) { }; const dataset: Accessor = createMemo(() => ({ - "data-valid": local.validationState === "valid" ? "" : undefined, - "data-invalid": local.validationState === "invalid" ? "" : undefined, "data-checked": state.isSelected() ? "" : undefined, - "data-required": local.required ? "" : undefined, - "data-disabled": local.disabled ? "" : undefined, - "data-readonly": local.readOnly ? "" : undefined, })); const context: SwitchContextValue = { - name: () => local.name ?? others.id!, value: () => local.value!, dataset, - validationState: () => local.validationState, - isChecked: () => state.isSelected(), - isRequired: () => local.required, - isDisabled: () => local.disabled, - isReadOnly: () => local.readOnly, - generateId: createGenerateId(() => others.id!), + checked: () => state.isSelected(), + generateId: createGenerateId(() => access(formControlProps.id)!), + toggle: () => state.toggle(), setIsChecked: isChecked => state.setIsSelected(isChecked), setIsFocused, }; return ( - - + + + (ref = el), local.ref)} + role="group" + id={access(formControlProps.id)} + onPointerDown={onPointerDown} + {...formControlContext.dataset()} + {...dataset()} + {...others} + > + + + + ); } + +interface SwitchRootChildProps extends Pick { + state: SwitchRootState; +} + +function SwitchRootChild(props: SwitchRootChildProps) { + const resolvedChildren = children(() => { + const body = props.children; + return isFunction(body) ? body(props.state) : body; + }); + + return <>{resolvedChildren()}; +} diff --git a/packages/core/src/switch/switch-thumb.tsx b/packages/core/src/switch/switch-thumb.tsx index 6d63f8a4..75975312 100644 --- a/packages/core/src/switch/switch-thumb.tsx +++ b/packages/core/src/switch/switch-thumb.tsx @@ -1,5 +1,6 @@ import { mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; +import { useFormControlContext } from "../form-control"; import { AsChildProp, Polymorphic } from "../polymorphic"; import { useSwitchContext } from "./switch-context"; @@ -9,6 +10,7 @@ export interface SwitchThumbProps extends OverrideComponentProps<"div", AsChildP * The thumb that is used to visually indicate whether the switch is on or off. */ export function SwitchThumb(props: SwitchThumbProps) { + const formControlContext = useFormControlContext(); const context = useSwitchContext(); props = mergeDefaultProps( @@ -18,5 +20,7 @@ export function SwitchThumb(props: SwitchThumbProps) { props ); - return ; + return ( + + ); } diff --git a/packages/core/src/switch/switch.test.tsx b/packages/core/src/switch/switch.test.tsx index 861f03d9..0608dca8 100644 --- a/packages/core/src/switch/switch.test.tsx +++ b/packages/core/src/switch/switch.test.tsx @@ -6,7 +6,7 @@ * https://github.com/adobe/react-spectrum/blob/810579b671791f1593108f62cdc1893de3a220e3/packages/@react-spectrum/switch/test/Switch.test.js */ -import { createPointerEvent, installPointerEvent } from "@kobalte/tests"; +import { installPointerEvent } from "@kobalte/tests"; import { fireEvent, render, screen } from "@solidjs/testing-library"; import * as Switch from "."; @@ -151,7 +151,6 @@ describe("Switch", () => { const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeFalsy(); expect(onChangeSpy).not.toHaveBeenCalled(); @@ -176,7 +175,6 @@ describe("Switch", () => { const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeTruthy(); fireEvent.click(input); @@ -195,7 +193,6 @@ describe("Switch", () => { const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeTruthy(); fireEvent.click(input); @@ -214,7 +211,6 @@ describe("Switch", () => { const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeFalsy(); fireEvent.click(input); @@ -224,6 +220,47 @@ describe("Switch", () => { expect(onChangeSpy.mock.calls[0][0]).toBe(true); }); + it("can be checked by clicking on the control", async () => { + render(() => ( + + + + + )); + + const input = screen.getByRole("switch") as HTMLInputElement; + const control = screen.getByTestId("control"); + + expect(input.checked).toBeFalsy(); + + fireEvent.click(control); + await Promise.resolve(); + + expect(input.checked).toBeTruthy(); + expect(onChangeSpy.mock.calls[0][0]).toBe(true); + }); + + it("can be checked by pressing the Space key on the control", async () => { + render(() => ( + + + + + )); + + const input = screen.getByRole("switch") as HTMLInputElement; + const control = screen.getByTestId("control"); + + expect(input.checked).toBeFalsy(); + + fireEvent.keyDown(control, { key: " " }); + fireEvent.keyUp(control, { key: " " }); + await Promise.resolve(); + + expect(input.checked).toBeTruthy(); + expect(onChangeSpy.mock.calls[0][0]).toBe(true); + }); + it("can be disabled", async () => { render(() => ( @@ -235,7 +272,6 @@ describe("Switch", () => { const label = screen.getByTestId("label"); const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.disabled).toBeTruthy(); expect(input.checked).toBeFalsy(); @@ -257,7 +293,6 @@ describe("Switch", () => { const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input).toHaveAttribute("aria-invalid", "true"); }); @@ -270,26 +305,27 @@ describe("Switch", () => { const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input).toHaveAttribute("aria-invalid", "true"); expect(input).toHaveAttribute("aria-errormessage", "test"); }); - it("supports 'aria-label'", () => { + it("supports visible label", async () => { render(() => ( - - + Label + )); const input = screen.getByRole("switch") as HTMLInputElement; + const label = screen.getByText("Label"); - expect(input.value).toBe("on"); - expect(input).toHaveAttribute("aria-label", "Label"); + expect(input).toHaveAttribute("aria-labelledby", label.id); + expect(label).toBeInstanceOf(HTMLLabelElement); + expect(label).toHaveAttribute("for", input.id); }); - it("supports 'aria-labelledby'", () => { + it("supports 'aria-labelledby'", async () => { render(() => ( @@ -298,24 +334,69 @@ describe("Switch", () => { const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input).toHaveAttribute("aria-labelledby", "foo"); }); - it("should combine 'aria-label' and 'aria-labelledby'", () => { + it("should combine 'aria-labelledby' if visible label is also provided", async () => { render(() => ( - + Label + )); const input = screen.getByRole("switch") as HTMLInputElement; + const label = screen.getByText("Label"); - expect(input.value).toBe("on"); - expect(input).toHaveAttribute("aria-labelledby", `foo ${input.id}`); + expect(input).toHaveAttribute("aria-labelledby", `foo ${label.id}`); + }); + + it("supports 'aria-label'", async () => { + render(() => ( + + + + )); + + const input = screen.getByRole("switch") as HTMLInputElement; + + expect(input).toHaveAttribute("aria-label", "My Label"); + }); + + it("should combine 'aria-labelledby' if visible label and 'aria-label' is also provided", async () => { + render(() => ( + + Label + + + )); + + const input = screen.getByRole("switch") as HTMLInputElement; + const label = screen.getByText("Label"); + + expect(input).toHaveAttribute("aria-labelledby", `foo ${label.id} ${input.id}`); + }); + + it("supports visible description", async () => { + render(() => ( + + + Description + + )); + + const input = screen.getByRole("switch") as HTMLInputElement; + const description = screen.getByText("Description"); + + expect(description.id).toBeDefined(); + expect(input.id).toBeDefined(); + expect(input).toHaveAttribute("aria-describedby", description.id); + + // check that generated ids are unique + expect(description.id).not.toBe(input.id); }); - it("supports 'aria-describedby'", () => { + it("supports 'aria-describedby'", async () => { render(() => ( @@ -324,10 +405,85 @@ describe("Switch", () => { const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input).toHaveAttribute("aria-describedby", "foo"); }); + it("should combine 'aria-describedby' if visible description", async () => { + render(() => ( + + + Description + + )); + + const input = screen.getByRole("switch") as HTMLInputElement; + const description = screen.getByText("Description"); + + expect(input).toHaveAttribute("aria-describedby", `${description.id} foo`); + }); + + it("supports visible error message when invalid", async () => { + render(() => ( + + + ErrorMessage + + )); + + const input = screen.getByRole("switch") as HTMLInputElement; + const errorMessage = screen.getByText("ErrorMessage"); + + expect(errorMessage.id).toBeDefined(); + expect(input.id).toBeDefined(); + expect(input).toHaveAttribute("aria-describedby", errorMessage.id); + + // check that generated ids are unique + expect(errorMessage.id).not.toBe(input.id); + }); + + it("should not be described by error message when not invalid", async () => { + render(() => ( + + + ErrorMessage + + )); + + const input = screen.getByRole("switch") as HTMLInputElement; + + expect(input).not.toHaveAttribute("aria-describedby"); + }); + + it("should combine 'aria-describedby' if visible error message when invalid", () => { + render(() => ( + + + ErrorMessage + + )); + + const input = screen.getByRole("switch") as HTMLInputElement; + const errorMessage = screen.getByText("ErrorMessage"); + + expect(input).toHaveAttribute("aria-describedby", `${errorMessage.id} foo`); + }); + + it("should combine 'aria-describedby' if visible description and error message when invalid", () => { + render(() => ( + + + Description + ErrorMessage + + )); + + const input = screen.getByRole("switch") as HTMLInputElement; + const description = screen.getByText("Description"); + const errorMessage = screen.getByText("ErrorMessage"); + + expect(input).toHaveAttribute("aria-describedby", `${description.id} ${errorMessage.id} foo`); + }); + it("can be read only", async () => { render(() => ( @@ -337,7 +493,6 @@ describe("Switch", () => { const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeTruthy(); expect(input).toHaveAttribute("aria-readonly", "true"); @@ -357,7 +512,6 @@ describe("Switch", () => { const input = screen.getByRole("switch") as HTMLInputElement; - expect(input.value).toBe("on"); expect(input.checked).toBeFalsy(); fireEvent.click(input); From bafb1f2a1ce05e732f9c211b9136248c6f1180f4 Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Thu, 4 May 2023 13:54:01 +0200 Subject: [PATCH 6/7] feat(radio): #207 --- .../docs/core/components/radio-group.mdx | 25 ++-- .../form-control/form-control-description.tsx | 8 +- .../form-control-error-message.tsx | 6 +- .../src/form-control/form-control-label.tsx | 5 +- packages/core/src/radio-group/index.tsx | 6 + .../radio-group/radio-group-item-context.tsx | 9 +- .../radio-group/radio-group-item-control.tsx | 29 +++- .../radio-group-item-description.tsx | 26 ++++ .../radio-group/radio-group-item-input.tsx | 10 +- .../radio-group/radio-group-item-label.tsx | 10 +- .../core/src/radio-group/radio-group-item.tsx | 27 +++- .../core/src/radio-group/radio-group.test.tsx | 139 +++++++++++++++++- 12 files changed, 256 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/radio-group/radio-group-item-description.tsx diff --git a/apps/docs/src/routes/docs/core/components/radio-group.mdx b/apps/docs/src/routes/docs/core/components/radio-group.mdx index 655b8598..184afa7b 100644 --- a/apps/docs/src/routes/docs/core/components/radio-group.mdx +++ b/apps/docs/src/routes/docs/core/components/radio-group.mdx @@ -42,6 +42,7 @@ The radio item consists of: - **RadioGroup.ItemControl**: The element that visually represents a radio button. - **RadioGroup.ItemIndicator**: The visual indicator rendered when the radio button is in a checked state. - **RadioGroup.ItemLabel**: The label that gives the user information on the radio button. +- **RadioGroup.ItemDescription**: The description that gives the user more information on the radio button. ```tsx @@ -52,6 +53,7 @@ The radio item consists of:
+
@@ -374,17 +376,18 @@ function HTMLFormExample() { ## Rendered elements -| Component | Default rendered element | -| :------------------------- | :----------------------- | -| `RadioGroup.Root` | `div` | -| `RadioGroup.Label` | `span` | -| `RadioGroup.Description` | `div` | -| `RadioGroup.ErrorMessage` | `div` | -| `RadioGroup.Item` | `label` | -| `RadioGroup.ItemInput` | `input` | -| `RadioGroup.ItemControl` | `div` | -| `RadioGroup.ItemIndicator` | `div` | -| `RadioGroup.ItemLabel` | `span` | +| Component | Default rendered element | +| :--------------------------- | :----------------------- | +| `RadioGroup.Root` | `div` | +| `RadioGroup.Label` | `span` | +| `RadioGroup.Description` | `div` | +| `RadioGroup.ErrorMessage` | `div` | +| `RadioGroup.Item` | `div` | +| `RadioGroup.ItemInput` | `input` | +| `RadioGroup.ItemControl` | `div` | +| `RadioGroup.ItemIndicator` | `div` | +| `RadioGroup.ItemLabel` | `label` | +| `RadioGroup.ItemDescription` | `div` | ## Accessibility diff --git a/packages/core/src/form-control/form-control-description.tsx b/packages/core/src/form-control/form-control-description.tsx index 8fb13120..cd5803e3 100644 --- a/packages/core/src/form-control/form-control-description.tsx +++ b/packages/core/src/form-control/form-control-description.tsx @@ -1,5 +1,5 @@ import { mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; -import { createEffect, onCleanup, splitProps } from "solid-js"; +import { createEffect, onCleanup } from "solid-js"; import { AsChildProp, Polymorphic } from "../polymorphic"; import { useFormControlContext } from "./form-control-context"; @@ -19,9 +19,7 @@ export function FormControlDescription(props: FormControlDescriptionProps) { props ); - const [local, others] = splitProps(props, ["id"]); + createEffect(() => onCleanup(context.registerDescription(props.id!))); - createEffect(() => onCleanup(context.registerDescription(local.id!))); - - return ; + return ; } diff --git a/packages/core/src/form-control/form-control-error-message.tsx b/packages/core/src/form-control/form-control-error-message.tsx index 9a85b0c0..71ec7d4a 100644 --- a/packages/core/src/form-control/form-control-error-message.tsx +++ b/packages/core/src/form-control/form-control-error-message.tsx @@ -28,7 +28,7 @@ export function FormControlErrorMessage(props: FormControlErrorMessageProps) { props ); - const [local, others] = splitProps(props, ["id", "forceMount"]); + const [local, others] = splitProps(props, ["forceMount"]); const isInvalid = () => context.validationState() === "invalid"; @@ -37,12 +37,12 @@ export function FormControlErrorMessage(props: FormControlErrorMessageProps) { return; } - onCleanup(context.registerErrorMessage(local.id!)); + onCleanup(context.registerErrorMessage(others.id!)); }); return ( - + ); } diff --git a/packages/core/src/form-control/form-control-label.tsx b/packages/core/src/form-control/form-control-label.tsx index 4697e01c..e2ffd8da 100644 --- a/packages/core/src/form-control/form-control-label.tsx +++ b/packages/core/src/form-control/form-control-label.tsx @@ -22,20 +22,19 @@ export function FormControlLabel(props: FormControlLabelProps) { props ); - const [local, others] = splitProps(props, ["ref", "id"]); + const [local, others] = splitProps(props, ["ref"]); const tagName = createTagName( () => ref, () => "label" ); - createEffect(() => onCleanup(context.registerLabel(local.id!))); + createEffect(() => onCleanup(context.registerLabel(others.id!))); return ( (ref = el), local.ref)} - id={local.id} for={tagName() === "label" ? context.fieldId() : undefined} {...context.dataset()} {...others} diff --git a/packages/core/src/radio-group/index.tsx b/packages/core/src/radio-group/index.tsx index a6b3513f..ea44a77a 100644 --- a/packages/core/src/radio-group/index.tsx +++ b/packages/core/src/radio-group/index.tsx @@ -14,6 +14,10 @@ import { RadioGroupItemControl as ItemControl, type RadioGroupItemControlProps, } from "./radio-group-item-control"; +import { + RadioGroupItemDescription as ItemDescription, + type RadioGroupItemDescriptionProps, +} from "./radio-group-item-description"; import { RadioGroupItemIndicator as ItemIndicator, type RadioGroupItemIndicatorOptions, @@ -40,6 +44,7 @@ export type { RadioGroupErrorMessageOptions, RadioGroupErrorMessageProps, RadioGroupItemControlProps, + RadioGroupItemDescriptionProps, RadioGroupItemIndicatorOptions, RadioGroupItemIndicatorProps, RadioGroupItemInputOptions, @@ -57,6 +62,7 @@ export { ErrorMessage, Item, ItemControl, + ItemDescription, ItemIndicator, ItemInput, ItemLabel, diff --git a/packages/core/src/radio-group/radio-group-item-context.tsx b/packages/core/src/radio-group/radio-group-item-context.tsx index 8bf260af..c4115497 100644 --- a/packages/core/src/radio-group/radio-group-item-context.tsx +++ b/packages/core/src/radio-group/radio-group-item-context.tsx @@ -3,10 +3,10 @@ import { Accessor, createContext, useContext } from "solid-js"; export interface RadioGroupItemDataSet { "data-valid": string | undefined; "data-invalid": string | undefined; - "data-checked": string | undefined; "data-required": string | undefined; "data-disabled": string | undefined; "data-readonly": string | undefined; + "data-checked": string | undefined; } export interface RadioGroupItemContextValue { @@ -14,7 +14,14 @@ export interface RadioGroupItemContextValue { dataset: Accessor; isSelected: Accessor; isDisabled: Accessor; + inputId: Accessor; + labelId: Accessor; + descriptionId: Accessor; + select: () => void; generateId: (part: string) => string; + registerInput: (id: string) => () => void; + registerLabel: (id: string) => () => void; + registerDescription: (id: string) => () => void; setIsFocused: (isFocused: boolean) => void; } diff --git a/packages/core/src/radio-group/radio-group-item-control.tsx b/packages/core/src/radio-group/radio-group-item-control.tsx index e048f2d8..d6bea85a 100644 --- a/packages/core/src/radio-group/radio-group-item-control.tsx +++ b/packages/core/src/radio-group/radio-group-item-control.tsx @@ -1,4 +1,5 @@ -import { mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; +import { callHandler, EventKey, mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; +import { JSX, splitProps } from "solid-js"; import { AsChildProp, Polymorphic } from "../polymorphic"; import { useRadioGroupItemContext } from "./radio-group-item-context"; @@ -18,5 +19,29 @@ export function RadioGroupItemControl(props: RadioGroupItemControlProps) { props ); - return ; + const [local, others] = splitProps(props, ["onClick", "onKeyDown"]); + + const onClick: JSX.EventHandlerUnion = e => { + callHandler(e, local.onClick); + + context.select(); + }; + + const onKeyDown: JSX.EventHandlerUnion = e => { + callHandler(e, local.onKeyDown); + + if (e.key === EventKey.Space) { + context.select(); + } + }; + + return ( + + ); } diff --git a/packages/core/src/radio-group/radio-group-item-description.tsx b/packages/core/src/radio-group/radio-group-item-description.tsx new file mode 100644 index 00000000..dfe7bfab --- /dev/null +++ b/packages/core/src/radio-group/radio-group-item-description.tsx @@ -0,0 +1,26 @@ +import { mergeDefaultProps, OverrideComponentProps } from "@kobalte/utils"; +import { createEffect, onCleanup } from "solid-js"; + +import { AsChildProp, Polymorphic } from "../polymorphic"; +import { useRadioGroupItemContext } from "./radio-group-item-context"; + +export interface RadioGroupItemDescriptionProps + extends OverrideComponentProps<"div", AsChildProp> {} + +/** + * The description that gives the user more information on the radio button. + */ +export function RadioGroupItemDescription(props: RadioGroupItemDescriptionProps) { + const context = useRadioGroupItemContext(); + + props = mergeDefaultProps( + { + id: context.generateId("description"), + }, + props + ); + + createEffect(() => onCleanup(context.registerDescription(props.id!))); + + return ; +} diff --git a/packages/core/src/radio-group/radio-group-item-input.tsx b/packages/core/src/radio-group/radio-group-item-input.tsx index 145316df..fc888a33 100644 --- a/packages/core/src/radio-group/radio-group-item-input.tsx +++ b/packages/core/src/radio-group/radio-group-item-input.tsx @@ -12,7 +12,7 @@ import { OverrideComponentProps, visuallyHiddenStyles, } from "@kobalte/utils"; -import { JSX, splitProps } from "solid-js"; +import { createEffect, JSX, onCleanup, splitProps } from "solid-js"; import { useFormControlContext } from "../form-control"; import { useRadioGroupContext } from "./radio-group-context"; @@ -54,6 +54,7 @@ export function RadioGroupItemInput(props: RadioGroupItemInputProps) { return ( [ local["aria-labelledby"], + radioContext.labelId(), // If there is both an aria-label and aria-labelledby, add the input itself has an aria-labelledby local["aria-labelledby"] != null && others["aria-label"] != null ? others.id : undefined, ] @@ -64,8 +65,9 @@ export function RadioGroupItemInput(props: RadioGroupItemInputProps) { const ariaDescribedBy = () => { return ( - [local["aria-describedby"], radioGroupContext.ariaDescribedBy()].filter(Boolean).join(" ") || - undefined + [local["aria-describedby"], radioContext.descriptionId(), radioGroupContext.ariaDescribedBy()] + .filter(Boolean) + .join(" ") || undefined ); }; @@ -98,6 +100,8 @@ export function RadioGroupItemInput(props: RadioGroupItemInputProps) { radioContext.setIsFocused(false); }; + createEffect(() => onCleanup(radioContext.registerInput(others.id!))); + return ( {} +export interface RadioGroupItemLabelProps extends ComponentProps<"label"> {} /** * The label that gives the user information on the radio button. @@ -18,5 +18,7 @@ export function RadioGroupItemLabel(props: RadioGroupItemLabelProps) { props ); - return ; + createEffect(() => onCleanup(context.registerLabel(props.id!))); + + return