Skip to main content

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, and it takes exactly four values: bottom-right (default), bottom-left, top-right, top-left. The value becomes a class name, so a fifth spelling is not rejected — it simply matches no rule, and the button keeps its position: fixed with no offsets and pins itself wherever it happened to sit in the flow. It looks like a layout bug rather than a typo.

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-left" />

It takes position (the four values above) and label, and a slot overrides the label. 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. Every one of them is #[Locked]: the browser can never change or widen them after mount, which is what makes them safe to trust on the server.

PropTypeDefaultWhat it does
mode?stringfrom ui.triggermodal or inline. inline renders the form in the page with no modal
categoriesarrayfrom categoriesthe category list this widget offers. Empty falls back to the configured list
contextarray[]context entries the host attaches to reports from this widget
fieldsarrayfrom fieldsper-field visibility, e.g. ['subject' => false]. A key here wins over the configuration; an absent key falls back to it
recipient?stringfrom mail.towhere this widget's reports are mailed
withScreenshot?boolnullnull 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 is availableCategories. Mount takes categories; the component holds the resolved list under the longer name.
  • A context entry is a plain array with key, label and value, optionally url and identifier. 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.
  • withScreenshot may narrow, never widen. With screenshot.strategy set to off the 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.