diff --git a/docs/_sidebar.md b/docs/_sidebar.md
index 19f7f51a..2216ebbe 100644
--- a/docs/_sidebar.md
+++ b/docs/_sidebar.md
@@ -35,6 +35,7 @@
- [Progress Bar](/components/progress-bar.md)
- [Progress Ring](/components/progress-ring.md)
- [Radio](/components/radio.md)
+ - [Radio Group](/components/radio-group.md)
- [Range](/components/range.md)
- [Rating](/components/rating.md)
- [Responsive Embed](/components/responsive-embed.md)
diff --git a/docs/components/radio-group.md b/docs/components/radio-group.md
new file mode 100644
index 00000000..71a528b7
--- /dev/null
+++ b/docs/components/radio-group.md
@@ -0,0 +1,29 @@
+# Radio Group
+
+[component-header:sl-radio-group]
+
+Radio Groups are used to group multiple radios so they function as a single control.
+
+```html preview
+
+ Item 1
+ Item 2
+ Item 3
+
+```
+
+## Examples
+
+### Hiding the Fieldset
+
+You can hide the fieldset and legend that wraps the radio group using the `no-fieldset` attribute. In this case, a label is still required for assistive devices to properly identify the control.
+
+```html preview
+
+ Item 1
+ Item 2
+ Item 3
+
+```
+
+[component-metadata:sl-radio-group]
diff --git a/docs/components/radio.md b/docs/components/radio.md
index fd0084ae..86e6bbe1 100644
--- a/docs/components/radio.md
+++ b/docs/components/radio.md
@@ -4,39 +4,31 @@
Radios allow the user to select one option from a group of many.
+Radios are designed to be used with [radio groups](/components/radio-group). As such, all of the examples on this page utilize them to demonstrate their correct usage.
+
```html preview
-Radio
+
+ Option 1
+ Option 2
+ Option 3
+
```
?> This component doesn't work with standard forms. Use [``](/components/form.md) instead.
## Examples
-### Checked
-
-Use the `checked` attribute to activate the radio.
-
-```html preview
-Checked
-```
-
### Disabled
-Use the `disabled` attribute to disable the radio.
+Use the `disabled` attribute to disable a radio.
```html preview
-Disabled
-```
-
-### Grouping Radios
-
-Radios are grouped based on their `name` attribute and scoped to the nearest form.
-
-```html preview
-Option 1
-Option 2
-Option 3
-Option 4
+
+ Option 1
+ Option 2
+ Option 3
+ Disabled
+
```
[component-metadata:sl-radio]
diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md
index 018a8cc8..b6147a49 100644
--- a/docs/resources/changelog.md
+++ b/docs/resources/changelog.md
@@ -6,6 +6,11 @@ Components with the Experimental badge
_During the beta period, these restrictions may be relaxed in the event of a mission-critical bug._ 🐛
+## Next
+
+- 🚨 BREAKING: `sl-radio` components must be located inside an `sl-radio-group` for proper accessibility [#218](https://github.com/shoelace-style/shoelace/issues/218)
+- Added `sl-radio-group` component [#218](https://github.com/shoelace-style/shoelace/issues/218)
+
## 2.0.0-beta.37
- Added `click()` method to `sl-checkbox`, `sl-radio`, and `sl-switch`
diff --git a/src/components/radio-group/radio-group.scss b/src/components/radio-group/radio-group.scss
new file mode 100644
index 00000000..0e1400fb
--- /dev/null
+++ b/src/components/radio-group/radio-group.scss
@@ -0,0 +1,37 @@
+@use '../../styles/component';
+@use '../../styles/mixins/hide';
+
+:host {
+ display: block;
+}
+
+.radio-group {
+ border: solid var(--sl-input-border-width) var(--sl-input-border-color);
+ border-radius: var(--sl-border-radius-medium);
+ padding: var(--sl-spacing-large);
+ padding-top: var(--sl-spacing-x-small);
+
+ .radio-group__label {
+ font-family: var(--sl-input-font-family);
+ font-size: var(--sl-input-font-size-medium);
+ font-weight: var(--sl-input-font-weight);
+ color: var(--sl-input-color);
+ padding: 0 var(--sl-spacing-xx-small);
+ }
+}
+
+::slotted(sl-radio:not(:last-of-type)) {
+ display: block;
+ margin-bottom: var(--sl-spacing-xx-small);
+}
+
+.radio-group--no-fieldset {
+ border: none;
+ padding: 0;
+ margin: 0;
+ min-width: 0;
+
+ .radio-group__label {
+ @include hide.visually-hidden;
+ }
+}
diff --git a/src/components/radio-group/radio-group.ts b/src/components/radio-group/radio-group.ts
new file mode 100644
index 00000000..b7c39840
--- /dev/null
+++ b/src/components/radio-group/radio-group.ts
@@ -0,0 +1,49 @@
+import { LitElement, html, unsafeCSS } from 'lit';
+import { customElement, property } from 'lit/decorators';
+import { classMap } from 'lit-html/directives/class-map';
+import styles from 'sass:./radio-group.scss';
+
+/**
+ * @since 2.0
+ * @status stable
+ *
+ * @slot - The default slot where radio controls are placed.
+ * @slot label - The radio group label. Required for proper accessibility. Alternatively, you can use the label prop.
+ *
+ * @part base - The component's base wrapper.
+ * @part label - The radio group label.
+ */
+@customElement('sl-radio-group')
+export default class SlRadioGroup extends LitElement {
+ static styles = unsafeCSS(styles);
+
+ /** The radio group label. Required for proper accessibility. Alternatively, you can use the label slot. */
+ @property() label = '';
+
+ /** Hides the fieldset and legend that surrounds the radio group. The label will still be read by screen readers. */
+ @property({ type: Boolean, attribute: 'no-fieldset' }) noFieldset = false;
+
+ render() {
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'sl-radio-group': SlRadioGroup;
+ }
+}
diff --git a/src/components/radio/radio.ts b/src/components/radio/radio.ts
index e16f5cdc..fffb2058 100644
--- a/src/components/radio/radio.ts
+++ b/src/components/radio/radio.ts
@@ -83,11 +83,14 @@ export default class SlRadio extends LitElement {
}
getAllRadios() {
- const form = this.closest('sl-form, form') || document.body;
+ const radioGroup = this.closest('sl-radio-group');
- if (!this.name) return [];
+ // Radios must be part of a radio group
+ if (!radioGroup) {
+ return [];
+ }
- return [...form.querySelectorAll('sl-radio')].filter((radio: this) => radio.name === this.name) as this[];
+ return [...radioGroup.querySelectorAll('sl-radio')].filter((radio: this) => radio.name === this.name) as this[];
}
getSiblingRadios() {
@@ -171,8 +174,8 @@ export default class SlRadio extends LitElement {
value=${ifDefined(this.value)}
?checked=${this.checked}
?disabled=${this.disabled}
- role="radio"
aria-checked=${this.checked ? 'true' : 'false'}
+ aria-disabled=${this.disabled ? 'true' : 'false'}
aria-labelledby=${this.labelId}
@click=${this.handleClick}
@blur=${this.handleBlur}
diff --git a/src/shoelace.ts b/src/shoelace.ts
index a3af2188..482edb3f 100644
--- a/src/shoelace.ts
+++ b/src/shoelace.ts
@@ -29,6 +29,7 @@ export { default as SlMenuLabel } from './components/menu-label/menu-label';
export { default as SlProgressBar } from './components/progress-bar/progress-bar';
export { default as SlProgressRing } from './components/progress-ring/progress-ring';
export { default as SlRadio } from './components/radio/radio';
+export { default as SlRadioGroup } from './components/radio-group/radio-group';
export { default as SlRange } from './components/range/range';
export { default as SlRating } from './components/rating/rating';
export { default as SlRelativeTime } from './components/relative-time/relative-time';