diff --git a/resources/js/stories/docs/Alert.mdx b/resources/js/stories/docs/Alert.mdx
index cdc98a53cd7..ff6cd00ab55 100644
--- a/resources/js/stories/docs/Alert.mdx
+++ b/resources/js/stories/docs/Alert.mdx
@@ -35,5 +35,10 @@ All heading levels (h1–h6) are styled identically, so choose the heading level
+## Live Regions
+By default, alerts are live regions so screen readers announce them when they appear. `warning` and `error` alerts are announced immediately, while other variants are announced politely.
+
+If the alert is static content that is present when the page loads, such as instructions in a form, set `:live="false"` so it isn't announced every time the page renders.
+
## Arguments
diff --git a/resources/js/tests/components/fieldtypes/InfoFieldtype.test.js b/resources/js/tests/components/fieldtypes/InfoFieldtype.test.js
new file mode 100644
index 00000000000..0e9ed332a3b
--- /dev/null
+++ b/resources/js/tests/components/fieldtypes/InfoFieldtype.test.js
@@ -0,0 +1,57 @@
+import { mount } from '@vue/test-utils';
+import { describe, expect, test } from 'vitest';
+import InfoFieldtype from '@/components/fieldtypes/InfoFieldtype.vue';
+import { Icon } from '@/components/ui';
+
+window.__ = (key) => key;
+
+function mountField(config = {}) {
+ return mount(InfoFieldtype, {
+ props: {
+ value: null,
+ handle: 'info',
+ config,
+ },
+ });
+}
+
+describe.each([
+ ['notice', 'default'],
+ ['tip', 'tip'],
+ ['warning', 'warning'],
+ ['important', 'error'],
+ ['success', 'success'],
+])('%s state', (state, variant) => {
+ test(`uses the ${variant} alert variant`, () => {
+ const wrapper = mountField({ state, content: 'Something happened.' });
+
+ expect(wrapper.get('[data-ui-alert]').attributes('data-variant')).toBe(variant);
+ });
+
+ test('is not announced as a live region', () => {
+ const wrapper = mountField({ state, content: 'Something happened.' });
+ const alert = wrapper.get('[data-ui-alert]');
+
+ expect(alert.attributes('role')).toBeUndefined();
+ expect(alert.attributes('aria-live')).toBeUndefined();
+ });
+});
+
+test('renders sanitized markdown with links and lists', () => {
+ const wrapper = mountField({
+ content: '- First item\n- [Statamic](https://statamic.com)\n\n',
+ });
+
+ expect(wrapper.findAll('li')).toHaveLength(2);
+ expect(wrapper.get('a').attributes()).toMatchObject({
+ href: 'https://statamic.com',
+ target: '_blank',
+ });
+ expect(wrapper.find('script').exists()).toBe(false);
+});
+
+test('uses a configured icon', () => {
+ const wrapper = mountField({ content: 'Hello', icon: 'lightbulb-idea' });
+
+ expect(wrapper.findComponent(Icon).props('name')).toBe('lightbulb-idea');
+});
diff --git a/resources/js/tests/components/ui/Alert.test.js b/resources/js/tests/components/ui/Alert.test.js
index cdcbcf16884..03d0da22354 100644
--- a/resources/js/tests/components/ui/Alert.test.js
+++ b/resources/js/tests/components/ui/Alert.test.js
@@ -24,3 +24,18 @@ test('tip variant is blue and uses the tip icon', () => {
});
expect(wrapper.findComponent(Icon).props('name')).toBe('lightbulb-idea');
});
+
+test('live region attributes can be disabled', () => {
+ const wrapper = mount(Alert, {
+ props: {
+ text: 'This is a static warning',
+ variant: 'warning',
+ live: false,
+ },
+ });
+
+ const alert = wrapper.get('[data-ui-alert]');
+
+ expect(alert.attributes('role')).toBeUndefined();
+ expect(alert.attributes('aria-live')).toBeUndefined();
+});
diff --git a/src/Fieldtypes/Info.php b/src/Fieldtypes/Info.php
new file mode 100644
index 00000000000..c982ddc2b8b
--- /dev/null
+++ b/src/Fieldtypes/Info.php
@@ -0,0 +1,60 @@
+ __('Content'),
+ 'fields' => [
+ 'content' => [
+ 'display' => __('Content'),
+ 'instructions' => __('statamic::fieldtypes.info.config.content'),
+ 'type' => 'textarea',
+ ],
+ ],
+ ],
+ [
+ 'display' => __('Appearance'),
+ 'fields' => [
+ 'state' => [
+ 'display' => __('State'),
+ 'instructions' => __('statamic::fieldtypes.info.config.state'),
+ 'type' => 'select',
+ 'default' => 'notice',
+ 'options' => [
+ 'notice' => __('Notice'),
+ 'tip' => __('Tip'),
+ 'warning' => __('Warning'),
+ 'important' => __('Important Warning'),
+ 'success' => __('Success'),
+ ],
+ 'width' => 50,
+ ],
+ 'icon' => [
+ 'display' => __('Icon'),
+ 'instructions' => __('statamic::fieldtypes.info.config.icon'),
+ 'type' => 'icon',
+ 'set' => 'default',
+ 'mode' => 'compact',
+ 'width' => 50,
+ ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php
index d62e7ab4691..0c5afab4a43 100644
--- a/src/Providers/ExtensionServiceProvider.php
+++ b/src/Providers/ExtensionServiceProvider.php
@@ -91,6 +91,7 @@ class ExtensionServiceProvider extends ServiceProvider
'hidden' => Fieldtypes\Hidden::class,
'html' => Fieldtypes\Html::class,
'icon' => Fieldtypes\Icon::class,
+ 'info' => Fieldtypes\Info::class,
'integer' => Fieldtypes\Integer::class,
'link' => Fieldtypes\Link::class,
'list' => Fieldtypes\Lists::class,
diff --git a/tests/Fieldtypes/InfoTest.php b/tests/Fieldtypes/InfoTest.php
new file mode 100644
index 00000000000..dc7f2f899ec
--- /dev/null
+++ b/tests/Fieldtypes/InfoTest.php
@@ -0,0 +1,41 @@
+assertSame(['special'], $fieldtype->categories());
+ $this->assertFalse($fieldtype->localizable());
+ $this->assertFalse($fieldtype->validatable());
+ $this->assertFalse($fieldtype->defaultable());
+ }
+
+ #[Test]
+ public function it_has_configurable_content_state_and_icon()
+ {
+ $fields = (new Info)->configFields();
+
+ $this->assertSame('textarea', $fields->get('content')->type());
+ $this->assertSame('select', $fields->get('state')->type());
+ $this->assertSame('notice', $fields->get('state')->get('default'));
+ $this->assertSame([
+ 'notice' => 'Notice',
+ 'tip' => 'Tip',
+ 'warning' => 'Warning',
+ 'important' => 'Important Warning',
+ 'success' => 'Success',
+ ], $fields->get('state')->get('options'));
+ $this->assertSame('icon', $fields->get('icon')->type());
+ $this->assertSame('default', $fields->get('icon')->get('set'));
+ $this->assertSame('compact', $fields->get('icon')->get('mode'));
+ }
+}