Skip to content

Commit e2cc368

Browse files
joshuablumjasonvargaclaude
authored
[6.x] Add Info fieldtype (#15368)
Co-authored-by: Jason Varga <jason@pixelfear.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 1840fe2 commit e2cc368

10 files changed

Lines changed: 218 additions & 2 deletions

File tree

lang/en/fieldtypes.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@
117117
'icon.config.mode' => 'Choose a dropdown or compact picker.',
118118
'icon.config.set' => 'Default CP icons, or a set registered with `Icons::register()`.',
119119
'icon.title' => 'Icon',
120+
'info.config.content' => 'The information to display. Markdown is supported, including links and lists.',
121+
'info.config.icon' => 'Choose an icon, or leave empty to use the state\'s default icon.',
122+
'info.config.state' => 'Choose the visual importance of the information.',
123+
'info.title' => 'Info',
120124
'integer.config.max' => 'The maximum allowed value.',
121125
'integer.config.min' => 'The minimum allowed value.',
122126
'integer.config.step' => 'The interval between valid numbers.',

resources/js/bootstrap/fieldtypes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import GroupFieldtype from '../components/fieldtypes/GroupFieldtype.vue';
3434
import HiddenFieldtype from '../components/fieldtypes/HiddenFieldtype.vue';
3535
import HtmlFieldtype from '../components/fieldtypes/HtmlFieldtype.vue';
3636
import IconFieldtype from '../components/fieldtypes/IconFieldtype.vue';
37+
import InfoFieldtype from '../components/fieldtypes/InfoFieldtype.vue';
3738
import IntegerFieldtype from '../components/fieldtypes/IntegerFieldtype.vue';
3839
import LinkFieldtype from '../components/fieldtypes/LinkFieldtype.vue';
3940
import LinkIndexFieldtype from '../components/fieldtypes/LinkIndexFieldtype.vue';
@@ -108,6 +109,7 @@ export default function registerFieldtypes(app) {
108109
app.component('hidden-fieldtype', HiddenFieldtype);
109110
app.component('html-fieldtype', HtmlFieldtype);
110111
app.component('icon-fieldtype', IconFieldtype);
112+
app.component('info-fieldtype', InfoFieldtype);
111113
app.component('integer-fieldtype', IntegerFieldtype);
112114
app.component('link-fieldtype', LinkFieldtype);
113115
app.component('link-fieldtype-index', LinkIndexFieldtype);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script setup>
2+
import Fieldtype from '@/components/fieldtypes/fieldtype';
3+
import { Alert } from '@/components/ui';
4+
import markdown from '@/util/markdown';
5+
import { computed } from 'vue';
6+
7+
const props = defineProps(Fieldtype.props);
8+
9+
const variant = computed(() => {
10+
return {
11+
notice: 'default',
12+
tip: 'tip',
13+
warning: 'warning',
14+
important: 'error',
15+
success: 'success',
16+
}[props.config.state] ?? 'default';
17+
});
18+
19+
const html = computed(() => markdown(__(props.config.content), { openLinksInNewTabs: true }));
20+
</script>
21+
22+
<template>
23+
<Alert :variant="variant" :icon="config.icon" :live="false">
24+
<div
25+
class="st-text-trim-start [&_a]:font-medium [&_a]:underline [&_ol]:list-decimal [&_ol]:ps-5 [&_ul]:list-disc [&_ul]:ps-5"
26+
v-html="html"
27+
/>
28+
</Alert>
29+
</template>

resources/js/components/ui/Alert.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const props = defineProps({
1212
variant: { type: String, default: 'default' },
1313
/** Icon name to display. [Browse available icons](/?path=/story/components-icon--all-icons) */
1414
icon: { type: String, default: null },
15+
/** Announce the alert to screen readers as a live region. Disable for static content that is present when the page loads. */
16+
live: { type: Boolean, default: true },
1517
});
1618
1719
const alertRole = computed(() => {
@@ -98,8 +100,8 @@ const defaultIcon = computed(() => {
98100
<template>
99101
<div
100102
:class="alertClasses"
101-
:role="alertRole"
102-
:aria-live="ariaLive"
103+
:role="live ? alertRole : undefined"
104+
:aria-live="live ? ariaLive : undefined"
103105
data-ui-alert
104106
:data-variant="variant"
105107
>

resources/js/stories/docs/Alert.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,10 @@ All heading levels (h1–h6) are styled identically, so choose the heading level
3535

3636
<Canvas of={AlertStories.RichContent} sourceState={'shown'} />
3737

38+
## Live Regions
39+
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.
40+
41+
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.
42+
3843
## Arguments
3944
<ArgTypes of={AlertStories} />
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { mount } from '@vue/test-utils';
2+
import { describe, expect, test } from 'vitest';
3+
import InfoFieldtype from '@/components/fieldtypes/InfoFieldtype.vue';
4+
import { Icon } from '@/components/ui';
5+
6+
window.__ = (key) => key;
7+
8+
function mountField(config = {}) {
9+
return mount(InfoFieldtype, {
10+
props: {
11+
value: null,
12+
handle: 'info',
13+
config,
14+
},
15+
});
16+
}
17+
18+
describe.each([
19+
['notice', 'default'],
20+
['tip', 'tip'],
21+
['warning', 'warning'],
22+
['important', 'error'],
23+
['success', 'success'],
24+
])('%s state', (state, variant) => {
25+
test(`uses the ${variant} alert variant`, () => {
26+
const wrapper = mountField({ state, content: 'Something happened.' });
27+
28+
expect(wrapper.get('[data-ui-alert]').attributes('data-variant')).toBe(variant);
29+
});
30+
31+
test('is not announced as a live region', () => {
32+
const wrapper = mountField({ state, content: 'Something happened.' });
33+
const alert = wrapper.get('[data-ui-alert]');
34+
35+
expect(alert.attributes('role')).toBeUndefined();
36+
expect(alert.attributes('aria-live')).toBeUndefined();
37+
});
38+
});
39+
40+
test('renders sanitized markdown with links and lists', () => {
41+
const wrapper = mountField({
42+
content: '- First item\n- [Statamic](https://statamic.com)\n\n<script>alert("nope")</script>',
43+
});
44+
45+
expect(wrapper.findAll('li')).toHaveLength(2);
46+
expect(wrapper.get('a').attributes()).toMatchObject({
47+
href: 'https://statamic.com',
48+
target: '_blank',
49+
});
50+
expect(wrapper.find('script').exists()).toBe(false);
51+
});
52+
53+
test('uses a configured icon', () => {
54+
const wrapper = mountField({ content: 'Hello', icon: 'lightbulb-idea' });
55+
56+
expect(wrapper.findComponent(Icon).props('name')).toBe('lightbulb-idea');
57+
});

resources/js/tests/components/ui/Alert.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,18 @@ test('tip variant is blue and uses the tip icon', () => {
2424
});
2525
expect(wrapper.findComponent(Icon).props('name')).toBe('lightbulb-idea');
2626
});
27+
28+
test('live region attributes can be disabled', () => {
29+
const wrapper = mount(Alert, {
30+
props: {
31+
text: 'This is a static warning',
32+
variant: 'warning',
33+
live: false,
34+
},
35+
});
36+
37+
const alert = wrapper.get('[data-ui-alert]');
38+
39+
expect(alert.attributes('role')).toBeUndefined();
40+
expect(alert.attributes('aria-live')).toBeUndefined();
41+
});

src/Fieldtypes/Info.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace Statamic\Fieldtypes;
4+
5+
use Statamic\Fields\Fieldtype;
6+
7+
use function Statamic\trans as __;
8+
9+
class Info extends Fieldtype
10+
{
11+
protected $categories = ['special'];
12+
protected $keywords = ['alert', 'message', 'notice', 'warning'];
13+
protected $icon = 'info';
14+
protected $localizable = false;
15+
protected $validatable = false;
16+
protected $defaultable = false;
17+
18+
protected function configFieldItems(): array
19+
{
20+
return [
21+
[
22+
'display' => __('Content'),
23+
'fields' => [
24+
'content' => [
25+
'display' => __('Content'),
26+
'instructions' => __('statamic::fieldtypes.info.config.content'),
27+
'type' => 'textarea',
28+
],
29+
],
30+
],
31+
[
32+
'display' => __('Appearance'),
33+
'fields' => [
34+
'state' => [
35+
'display' => __('State'),
36+
'instructions' => __('statamic::fieldtypes.info.config.state'),
37+
'type' => 'select',
38+
'default' => 'notice',
39+
'options' => [
40+
'notice' => __('Notice'),
41+
'tip' => __('Tip'),
42+
'warning' => __('Warning'),
43+
'important' => __('Important Warning'),
44+
'success' => __('Success'),
45+
],
46+
'width' => 50,
47+
],
48+
'icon' => [
49+
'display' => __('Icon'),
50+
'instructions' => __('statamic::fieldtypes.info.config.icon'),
51+
'type' => 'icon',
52+
'set' => 'default',
53+
'mode' => 'compact',
54+
'width' => 50,
55+
],
56+
],
57+
],
58+
];
59+
}
60+
}

src/Providers/ExtensionServiceProvider.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class ExtensionServiceProvider extends ServiceProvider
9191
'hidden' => Fieldtypes\Hidden::class,
9292
'html' => Fieldtypes\Html::class,
9393
'icon' => Fieldtypes\Icon::class,
94+
'info' => Fieldtypes\Info::class,
9495
'integer' => Fieldtypes\Integer::class,
9596
'link' => Fieldtypes\Link::class,
9697
'list' => Fieldtypes\Lists::class,

tests/Fieldtypes/InfoTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Tests\Fieldtypes;
4+
5+
use PHPUnit\Framework\Attributes\Test;
6+
use Statamic\Fieldtypes\Info;
7+
use Tests\TestCase;
8+
9+
class InfoTest extends TestCase
10+
{
11+
#[Test]
12+
public function it_is_a_non_data_fieldtype()
13+
{
14+
$fieldtype = new Info;
15+
16+
$this->assertSame(['special'], $fieldtype->categories());
17+
$this->assertFalse($fieldtype->localizable());
18+
$this->assertFalse($fieldtype->validatable());
19+
$this->assertFalse($fieldtype->defaultable());
20+
}
21+
22+
#[Test]
23+
public function it_has_configurable_content_state_and_icon()
24+
{
25+
$fields = (new Info)->configFields();
26+
27+
$this->assertSame('textarea', $fields->get('content')->type());
28+
$this->assertSame('select', $fields->get('state')->type());
29+
$this->assertSame('notice', $fields->get('state')->get('default'));
30+
$this->assertSame([
31+
'notice' => 'Notice',
32+
'tip' => 'Tip',
33+
'warning' => 'Warning',
34+
'important' => 'Important Warning',
35+
'success' => 'Success',
36+
], $fields->get('state')->get('options'));
37+
$this->assertSame('icon', $fields->get('icon')->type());
38+
$this->assertSame('default', $fields->get('icon')->get('set'));
39+
$this->assertSame('compact', $fields->get('icon')->get('mode'));
40+
}
41+
}

0 commit comments

Comments
 (0)