diff --git a/.changeset/itchy-pens-thank.md b/.changeset/itchy-pens-thank.md new file mode 100644 index 00000000..a30b4d50 --- /dev/null +++ b/.changeset/itchy-pens-thank.md @@ -0,0 +1,11 @@ +--- +"@kobalte/tailwindcss": patch +"@kobalte/core": patch +--- + +fix: + +- #205 +- #206 +- #207 +- remove Kobalte UI colors diff --git a/apps/docs/src/VERSIONS.ts b/apps/docs/src/VERSIONS.ts index c523bcb8..52b912fb 100644 --- a/apps/docs/src/VERSIONS.ts +++ b/apps/docs/src/VERSIONS.ts @@ -21,6 +21,7 @@ export const CORE_VERSIONS = [ "0.9.2", "0.9.3", "0.9.4", + "0.9.5", ].reverse(); export const LATEST_CORE_CHANGELOG_URL = `/docs/changelog/${CORE_VERSIONS[0].replaceAll(".", "-")}`; 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/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/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/changelog/0-9-5.mdx b/apps/docs/src/routes/docs/changelog/0-9-5.mdx new file mode 100644 index 00000000..6e3879fc --- /dev/null +++ b/apps/docs/src/routes/docs/changelog/0-9-5.mdx @@ -0,0 +1,9 @@ +# v0.9.5 + +**May 4, 2023**. + +## Bug fixes + +- [#205](https://github.com/kobaltedev/kobalte/pull/205) +- [#206](https://github.com/kobaltedev/kobalte/pull/206) +- [#207](https://github.com/kobaltedev/kobalte/pull/207) diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index 755a072b..87df5c68 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", @@ -104,11 +104,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [ { title: "Radio Group", href: "/docs/core/components/radio-group", + status: "updated", }, { title: "Select", href: "/docs/core/components/select", - status: "updated", }, { title: "Separator", @@ -117,6 +117,7 @@ const CORE_NAV_SECTIONS: NavSection[] = [ { title: "Switch", href: "/docs/core/components/switch", + status: "updated", }, { title: "Tabs", @@ -137,7 +138,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/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/checkbox.mdx b/apps/docs/src/routes/docs/core/components/checkbox.mdx index 25722f97..4d7d3fe9 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. @@ -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/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/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/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/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/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..332587fe 100644 --- a/packages/core/src/checkbox/checkbox-context.tsx +++ b/packages/core/src/checkbox/checkbox-context.tsx @@ -1,27 +1,17 @@ -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..0fd81bd4 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" - name={context.name()} + id={fieldProps.id()} + name={formControlContext.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..0caebc80 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,31 @@ 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", + "indeterminate", + "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( @@ -147,41 +150,38 @@ 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!, 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/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/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/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/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 d8b1df8b..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,8 +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 { @@ -12,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