Placing the trigger
The built-in floating button is the default. ui.trigger controls it:
fab(default) — the package places the floating button itself.inline— no modal; the form is part of the page (a contact form, say).none— you place the trigger yourself.
ui.position decides which corner the built-in button sits in: bottom-end (default),
bottom-start, top-end or top-start. The corner is read in the writing direction, so
end is the side a line of text ends on. A left-to-right page puts bottom-end at the bottom
right, and a right-to-left page mirrors it to the bottom left, the way it mirrors the rest of
its layout. Both view trees read it that way, so switching trees never moves the button.
The physical spellings bottom-right, bottom-left, top-right and top-left still work.
Each means its left-to-right corner, so bottom-right is bottom-end: the same place on a
left-to-right page, and mirrored on a right-to-left one. A value that is none of these eight
puts the button in the default corner.
ui.fab_icon picks the glyph on the WireKit tree's floating button. It takes a WireKit icon alias and
defaults to message, because a plus reads as "create something" rather than "give feedback". Set it
to plus for the glyph earlier releases showed. A name WireKit does not declare keeps the plus instead
of failing, and so does a WireKit older than 2.27. The glyph is drawn by your icon set, so without Blade
Icons installed WireKit shows its plus whatever this says. The plain tree's button carries text and
ignores the key.
A mode mount prop overrides this per widget, so one page can carry an inline contact
form and a modal at the same time: <livewire:visual-feedback.report-widget mode="inline" />.
For your own trigger, use the component anywhere on the page:
<x-visual-feedback::trigger class="your-button-class">Report a problem</x-visual-feedback::trigger>
The trigger ships unstyled on purpose. In the plain view tree it renders a bare
<button class="visual-feedback-trigger"> and the bundled stylesheet carries no rule for
that class, so whatever you pass through class is the only styling it has — which is the
point: a trigger sitting in your navigation should wear your buttons, not this package's.
The class stays on the element as a stable hook, so you can target it as well.
Give it your own button styles, and if you have none to hand, give it at least 44×44 CSS px and a 16px font size: with browser defaults alone it renders around 21px tall, which is a hard target to hit on a phone. The WireKit tree needs none of this — its trigger is a WireKit button and inherits your design system's sizes.
If what you want is the floating button but somewhere else in the document — inside a layout that the widget's own position would fight, or on some pages only — the same component the package uses is public:
<x-visual-feedback::fab position="bottom-start" />
It takes position (the values above) and label. In the plain tree a slot replaces the
label as the button's text. In the WireKit tree the button is a round 56 px icon button: a slot
is icon markup, hidden from assistive technology, and the name stays label, so pass words as
label there. Pair
it with ui.trigger=none, or you get two floating buttons: the built-in one renders whenever
the trigger is fab and the widget is in modal mode, and it does not know you placed
another.
Both components render nothing at all while the package is switched off, so a trigger you placed yourself disappears with the rest of it rather than opening an empty dialog — see Abuse protection.
Or open the widget from your own code — any element, any framework. The widget listens for one window event:
<button type="button" id="report-a-problem">Report a problem</button>
// resources/js/app.js, or any script your page already loads
document.getElementById('report-a-problem')?.addEventListener('click', () => {
window.dispatchEvent(new Event('visual-feedback:open'));
});
The handler is bound in a script rather than written as an onclick attribute, and that is
worth keeping. An inline event handler falls under script-src-attr, which falls back to
script-src — so under any policy without 'unsafe-inline' the attribute is dropped and the
button renders perfectly, looks right, and does nothing when clicked. The same failure as the
one the Integration contract describes for style attributes, one
level up and just as quiet. If your application has no content security policy the attribute
form works fine; this one works either way.
The inline form takes the same component with a mode:
@livewire('visual-feedback.report-widget', ['mode' => 'inline'])
Per-instance mount props
Six props override the configuration for one widget, so a documentation page and a
billing page in the same application can offer different forms. None of them can be
changed or widened by the browser after mount, which is what makes them safe to trust on the
server: categories, context, fields and withScreenshot are sealed at mount and restored
on every request, recipient has to stay an address the configuration permits, and mode
stays one the widget can render.
A request that breaks the seal gets no fallback to the configuration, which would offer every
configured category and turn the capture back on. The widget switches the capture off instead
and refuses to send until the page is reloaded, telling the reporter to copy their message
first. Rotating APP_KEY breaks the seal of every page that is open at the time in the same way.
A request that tries to change a sealed prop leaves a warning in your log, naming the widget and the prop but never the value that arrived. It is written at most once a minute per prop, so a client that repeats the attempt cannot fill your log with it.
| Prop | Type | Default | What it does |
|---|---|---|---|
mode | ?string | from ui.trigger | modal or inline. inline renders the form in the page with no modal |
categories | array | from categories | the category list this widget offers. Empty falls back to the configured list |
context | array | [] | context entries the host attaches to reports from this widget |
fields | array | from fields | the mode of each field, off, optional or required, or the boolean this prop has always taken, e.g. ['subject' => false]. A key here wins over the configuration, in the form and in the validation; an absent key falls back to it |
recipient | ?string | from mail.to | where this widget's reports are mailed |
withScreenshot | ?bool | null | null follows the default (capture in modal mode only); false removes the capture from this widget, true adds it |
<livewire:visual-feedback.report-widget
mode="inline"
:categories="['bug', 'billing']"
:fields="['subject' => false]"
:context="[['key' => 'plan', 'label' => 'Plan', 'value' => $team->plan]]"
recipient="[email protected]"
:with-screenshot="false"
/>
The same names work through @livewire(), in their PHP spelling:
@livewire('visual-feedback.report-widget', [
'mode' => 'inline',
'categories' => ['bug', 'billing'],
'recipient' => '[email protected]',
'withScreenshot' => false,
])
Three details that are easy to get wrong:
- The prop is
categories, the property isavailableCategories. Mount takescategories; the component holds the resolved list under the longer name. - A context entry is a plain array with
key,labelandvalue, optionallyurlandidentifier. Entries whose values are not strings are dropped rather than rendered, so a typo here loses the entry silently. For context that should be on every report, use a context provider instead — see Configuration. withScreenshotmay narrow, never widen. Withscreenshot.strategyset tooffthe capture stays off everywhere, whatever a widget asks for.
recipient is visible in the page source
Livewire serializes a component's public properties into a wire:snapshot attribute on the
rendered element, and #[Locked] governs writes, not visibility. So a recipient you
mount is readable by anyone who views the source of that page, and by any crawler that
fetches it.
That is fine for an address you would print in a footer anyway. It is not fine for an internal team alias you would rather not publish. For that case, deliver through your own channel — see Delivery channels — and pick the address on the server, where the browser never sees it.