Add charts and a rich text editor to a Laravel app
Chart.js and Tiptap beside Bladewell: chart data without inline scripts, your theme's colours, an editor in a field with its label and errors, Livewire, and cleaning the HTML before you save it.
php artisan bladewell:add field select
Uses Field and Select. New to Bladewell? Install it first.
Use a library for these
Charts and rich text editors are big, and well-maintained libraries already do them: Chart.js for charts, Tiptap for editing. Bladewell doesn't wrap either. This guide plugs them in beside it so they fit: data passed without inline scripts, colours from your theme, the editor in a field like any other, and both working inside Livewire.
These are your app's dependencies, not Bladewell's: install them with npm in your app. Load them from your own Vite build rather than a CDN, so a Content Security Policy of script-src 'self' covers them. If yours also limits style-src, check each library's documentation for what it needs.
A chart
Install it with npm install chart.js. The numbers go into the page as JSON in a <script type="application/json">, which the browser never runs, so no inline script is needed. A table with the same numbers is what a screen reader reads, since a canvas is only a picture.
<figure class="border-line bg-surface rounded-2xl border p-5">
<figcaption id="sales-title" class="text-foreground font-semibold">Sales this week</figcaption>
{{-- The numbers go in as JSON for the script to read, not as a script that runs: a Content Security Policy without
'unsafe-inline' allows it. --}}
<script type="application/json" data-chart-data>@json(['labels' => array_keys($sales), 'values' => array_values($sales)])</script>
<div class="relative mt-4 h-64">
<canvas data-chart role="img" aria-labelledby="sales-title" aria-describedby="sales-table"></canvas>
</div>
{{-- The same numbers as a table: what a screen reader reads, since a canvas is only a picture. --}}
<table id="sales-table" class="sr-only">
<tr><th>Day</th><th>Sales</th></tr>
@foreach ($sales as $day => $amount)
<tr><td>{{ $day }}</td><td>{{ $amount }}</td></tr>
@endforeach
</table>
</figure>
The script reads the JSON and draws with your theme's colours, taken from its CSS variables, so a dark theme changes the chart too.
import { Chart } from 'chart.js/auto';
// The theme's colours, as the page has them now (dark theme included). They're oklch(), which Chart.js may not read,
// so each is painted onto a one-pixel canvas and read back as rgba.
const probe = Object.assign(document.createElement('canvas'), { width: 1, height: 1 }).getContext('2d', { willReadFrequently: true });
function token(name) {
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return `rgba(${r}, ${g}, ${b}, ${a / 255})`;
}
function draw(canvas, { labels, values }) {
canvas.chart?.destroy();
canvas.chart = new Chart(canvas, {
type: 'bar',
data: { labels, datasets: [{ label: 'Sales', data: values, backgroundColor: token('--color-primary'), borderRadius: 6 }] },
options: {
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false }, ticks: { color: token('--color-muted') } },
y: { grid: { color: token('--color-line') }, ticks: { color: token('--color-muted') } },
},
},
});
}
document.querySelectorAll('canvas[data-chart]').forEach((canvas) => {
draw(canvas, JSON.parse(canvas.closest('figure').querySelector('[data-chart-data]').textContent));
});
// New numbers from a Livewire component: $this->dispatch('chart-data', labels: [...], values: [...]).
window.addEventListener('chart-data', (event) => {
document.querySelectorAll('canvas[data-chart]').forEach((canvas) => draw(canvas, event.detail));
});
A chart in Livewire
Put the canvas in wire:ignore, so a render doesn't wipe it, and send new numbers by event when they change.
<div>
<x-widget.select label="Period" wire:model.live="period" :options="['week' => 'This week', 'month' => 'This month']" />
{{-- wire:ignore: the chart is the script's. A render would wipe the canvas; new numbers come by event instead. --}}
<div wire:ignore class="relative mt-4 h-64">
<canvas data-chart role="img" aria-label="Sales"></canvas>
</div>
</div>
namespace App\Livewire;
use Livewire\Component;
class SalesChart extends Component
{
public string $period = 'week';
// A new period: the chart's numbers go to the script, which redraws it.
public function updatedPeriod(): void
{
$sales = $this->sales();
$this->dispatch('chart-data', labels: array_keys($sales), values: array_values($sales));
}
/** @return array<string, int> */
private function sales(): array
{
return Order::salesBy($this->period);
}
public function render()
{
return view('livewire.sales-chart');
}
}
A rich text editor
Install it with npm install @tiptap/core @tiptap/starter-kit. Put the editor in Bladewell's field and it gets a label, a hint and an error like your other fields. A hidden input carries its HTML: that's what submits, and what wire:model binds. The editor itself sits in wire:ignore.
{{-- The field frame gives the editor a label, a hint and an error like any other field. The label names a group by id
here, as the editor isn't a form control a <label> can point at. --}}
<x-widget.field id="body" label="Body" :error="$errors->get('body')" info="Bold, italic and lists." :labels-control="false" bare>
{{-- What the editor writes, as HTML: what submits as body, and what wire:model binds. --}}
<input type="hidden" name="body" value="{{ old('body') }}" data-editor-input>
<div class="border-line bg-field rounded-[20px] border">
<div role="toolbar" aria-label="Formatting" aria-controls="body-editor" class="border-line flex gap-1 border-b p-2">
<button type="button" data-editor-command="toggleBold" data-editor-mark="bold" aria-pressed="false" class="hover:bg-line aria-pressed:bg-line rounded-lg px-2.5 py-1 text-sm font-bold">B</button>
<button type="button" data-editor-command="toggleItalic" data-editor-mark="italic" aria-pressed="false" class="hover:bg-line aria-pressed:bg-line rounded-lg px-2.5 py-1 text-sm italic">I</button>
<button type="button" data-editor-command="toggleBulletList" data-editor-mark="bulletList" aria-pressed="false" aria-label="Bulleted list" class="hover:bg-line aria-pressed:bg-line rounded-lg px-2.5 py-1 text-sm">• List</button>
</div>
{{-- wire:ignore: the editor's content is the script's; it reports changes through the hidden input. --}}
<div wire:ignore>
<div id="body-editor" data-editor class="text-foreground min-h-40 p-4"></div>
</div>
</div>
</x-widget.field>
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
document.querySelectorAll('[data-editor]').forEach((element) => {
const field = element.closest('[data-field]');
const input = field.querySelector('[data-editor-input]');
const buttons = [...field.querySelectorAll('[data-editor-command]')];
const editor = new Editor({
element,
extensions: [StarterKit],
content: input.value,
// Named by the field's label, and announced as a text box that takes several lines.
editorProps: {
attributes: { role: 'textbox', 'aria-multiline': 'true', 'aria-labelledby': `${input.closest('[data-field]').querySelector('[id$="-label"]').id}`, class: 'outline-none min-h-32' },
},
// Into the hidden input, and tell wire:model and the field (which clears its error as you edit).
onUpdate: ({ editor: current }) => {
input.value = current.isEmpty ? '' : current.getHTML();
input.dispatchEvent(new Event('input', { bubbles: true }));
},
// Each button says whether it's on where the cursor is.
onTransaction: ({ editor: current }) => {
buttons.forEach((button) => button.setAttribute('aria-pressed', String(current.isActive(button.dataset.editorMark))));
},
});
buttons.forEach((button) => button.addEventListener('click', () => editor.chain().focus()[button.dataset.editorCommand]().run()));
});
Clean the HTML before saving it
The editor sends HTML, and anyone can send any HTML to your endpoint, not only through the editor. Keep only safe elements before saving, for example with composer require symfony/html-sanitizer, and print it with {!! !!} only after that.
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
// The editor sends HTML, and anyone can send any HTML: keep only safe elements before saving. Print it with {!! !!}
// only once it has been through this.
$sanitizer = new HtmlSanitizer((new HtmlSanitizerConfig())->allowSafeElements());
$post->body = $sanitizer->sanitize($request->validated('body'));
$post->save();