Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lang/en/fieldtypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@
'icon.config.mode' => 'Choose a dropdown or compact picker.',
'icon.config.set' => 'Default CP icons, or a set registered with `Icons::register()`.',
'icon.title' => 'Icon',
'info.config.content' => 'The information to display. Markdown is supported, including links and lists.',
'info.config.icon' => 'Choose an icon, or leave empty to use the state\'s default icon.',
'info.config.state' => 'Choose the visual importance of the information.',
'info.title' => 'Info',
'integer.config.max' => 'The maximum allowed value.',
'integer.config.min' => 'The minimum allowed value.',
'integer.config.step' => 'The interval between valid numbers.',
Expand Down
2 changes: 2 additions & 0 deletions resources/js/bootstrap/fieldtypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import GroupFieldtype from '../components/fieldtypes/GroupFieldtype.vue';
import HiddenFieldtype from '../components/fieldtypes/HiddenFieldtype.vue';
import HtmlFieldtype from '../components/fieldtypes/HtmlFieldtype.vue';
import IconFieldtype from '../components/fieldtypes/IconFieldtype.vue';
import InfoFieldtype from '../components/fieldtypes/InfoFieldtype.vue';
import IntegerFieldtype from '../components/fieldtypes/IntegerFieldtype.vue';
import LinkFieldtype from '../components/fieldtypes/LinkFieldtype.vue';
import LinkIndexFieldtype from '../components/fieldtypes/LinkIndexFieldtype.vue';
Expand Down Expand Up @@ -108,6 +109,7 @@ export default function registerFieldtypes(app) {
app.component('hidden-fieldtype', HiddenFieldtype);
app.component('html-fieldtype', HtmlFieldtype);
app.component('icon-fieldtype', IconFieldtype);
app.component('info-fieldtype', InfoFieldtype);
app.component('integer-fieldtype', IntegerFieldtype);
app.component('link-fieldtype', LinkFieldtype);
app.component('link-fieldtype-index', LinkIndexFieldtype);
Expand Down
29 changes: 29 additions & 0 deletions resources/js/components/fieldtypes/InfoFieldtype.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script setup>
import Fieldtype from '@/components/fieldtypes/fieldtype';
import { Alert } from '@/components/ui';
import markdown from '@/util/markdown';
import { computed } from 'vue';

const props = defineProps(Fieldtype.props);

const variant = computed(() => {
return {
notice: 'default',
tip: 'tip',
warning: 'warning',
important: 'error',
success: 'success',
}[props.config.state] ?? 'default';
});

const html = computed(() => markdown(__(props.config.content), { openLinksInNewTabs: true }));
</script>

<template>
<Alert :variant="variant" :icon="config.icon" :live="false">
<div
class="st-text-trim-start [&_a]:font-medium [&_a]:underline [&_ol]:list-decimal [&_ol]:ps-5 [&_ul]:list-disc [&_ul]:ps-5"
v-html="html"
/>
</Alert>
</template>
6 changes: 4 additions & 2 deletions resources/js/components/ui/Alert.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const props = defineProps({
variant: { type: String, default: 'default' },
/** Icon name to display. [Browse available icons](/?path=/story/components-icon--all-icons) */
icon: { type: String, default: null },
/** Announce the alert to screen readers as a live region. Disable for static content that is present when the page loads. */
live: { type: Boolean, default: true },
});

const alertRole = computed(() => {
Expand Down Expand Up @@ -98,8 +100,8 @@ const defaultIcon = computed(() => {
<template>
<div
:class="alertClasses"
:role="alertRole"
:aria-live="ariaLive"
:role="live ? alertRole : undefined"
:aria-live="live ? ariaLive : undefined"
data-ui-alert
:data-variant="variant"
>
Expand Down
5 changes: 5 additions & 0 deletions resources/js/stories/docs/Alert.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,10 @@ All heading levels (h1–h6) are styled identically, so choose the heading level

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

## 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
<ArgTypes of={AlertStories} />
57 changes: 57 additions & 0 deletions resources/js/tests/components/fieldtypes/InfoFieldtype.test.js
Original file line number Diff line number Diff line change
@@ -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<script>alert("nope")</script>',
});

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');
});
15 changes: 15 additions & 0 deletions resources/js/tests/components/ui/Alert.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
60 changes: 60 additions & 0 deletions src/Fieldtypes/Info.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Statamic\Fieldtypes;

use Statamic\Fields\Fieldtype;

use function Statamic\trans as __;

class Info extends Fieldtype
{
protected $categories = ['special'];
protected $keywords = ['alert', 'message', 'notice', 'warning'];
protected $icon = 'info';
protected $localizable = false;
protected $validatable = false;
protected $defaultable = false;

protected function configFieldItems(): array
{
return [
[
'display' => __('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,
],
],
],
];
}
}
1 change: 1 addition & 0 deletions src/Providers/ExtensionServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions tests/Fieldtypes/InfoTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace Tests\Fieldtypes;

use PHPUnit\Framework\Attributes\Test;
use Statamic\Fieldtypes\Info;
use Tests\TestCase;

class InfoTest extends TestCase
{
#[Test]
public function it_is_a_non_data_fieldtype()
{
$fieldtype = new Info;

$this->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'));
}
}
Loading