← Back to Examples

🧩 Colocated Hooks

Keep the callback JS next to the markup — no demos.js, no bundler

What this shows
The wrapper is declarative and doesn't wrap JS callbacks (a callback is a function; it can't be a HEEx attr or be pushed from the server). So dynamic per-day rendering — getDateMetadataCallback, customStylesCallback, … — is a client-side phx-hook concern. Every other example wires those in priv/static/assets/demos.js. This page instead uses a colocated hook (LiveView 1.1+): the hook JS lives inline in this colocated_hooks_live.ex, right under the picker it drives.
runtime vs extracted
test_app has no bundler — it serves hand-authored ESM through an importmap. So this demo uses the runtime variant: the <script :type={ColocatedHook} runtime> compiles to an inline <script> that registers a window["phx_hook_…"] global — the LiveView client discovers it with no bundler and no app.js change. In a normal esbuild app you drop runtime, write export default {…}, and import the generated phoenix-colocated/<app> manifest into your LiveSocket hooks.

Dynamic pricing badges — hook JS colocated in this .ex

Weekends are $220 (red), weekdays $150 (green). The badge value comes from getDateMetadataCallback; the color comes from the class it assigns plus the CSS that customStylesCallback injects into the picker's shadow DOM. Both are set by the .PriceBadges hook defined at the bottom of this template — the picker keeps hook={true} for its own events, and the colocated hook rides on a parent element (one phx-hook per element).

Why the badges appear a beat late
The inline calendar paints as soon as the custom element upgrades (right after the JS loads). The callback only attaches once the LiveSocket connects and this hook's mounted() runs — so the badges pop in a moment after the grid is already on screen. That's inherent to attaching callbacks from a client hook, not a cost of colocation: a bundled demos.js hook would flash identically. The popup pickers on Badges & Tooltips hide it because their calendar isn't rendered until you open it (callbacks already set by then).

Below is the hook itself — the actual code this page runs, colocated in colocated_hooks_live.ex (not fetched from demos.js):

<%!-- in your LiveView render/1 — hook rides on a PARENT element, picker keeps hook={true} --%>
<div id="pricing-cb" phx-hook=".PriceBadges">
  <.web_daterangepicker id="colocated-pricing" hook={true}
    selection_mode="range" visible_months_count={2} positioning_mode="inline" />
</div>

<%!-- runtime variant (no bundler): the body EVALUATES TO the hook object (no `export default`).
     It compiles to an inline <script> that registers a window global the LV client discovers. --%>
<script :type={Phoenix.LiveView.ColocatedHook} name=".PriceBadges" runtime>
  {
    mounted() {
      const el = this.el.querySelector("web-daterangepicker");
      customElements.whenDefined("web-daterangepicker").then(() => {
        el.getDateMetadataCallback = ({ date }) => {           // ONE v2.0.0 context object
          const price = (date.getDay() === 0 || date.getDay() === 6) ? 220 : 150;
          const tier = price >= 200 ? "price-high" : "price-low";
          return { badgeText: "$" + price, badgeClass: "badge-number " + tier };
        };
        el.customStylesCallback = () =>                        // inject shadow-DOM CSS
          ".drp__badge-cell.price-high{background:#fee2e2;color:#991b1b}" +
          ".drp__badge-cell.price-low{background:#d1fae5;color:#065f46}";
      });
    }
  }
</script>

# esbuild app instead? drop `runtime`, use `export default { … }`, and
#   import { hooks } from "phoenix-colocated/my_app"   into your LiveSocket hooks.

Same logic, different home: compare with priv/static/assets/demos.js, which drives the Badges & Tooltips page.

🔌 Server round-trip
0
The LiveView server saw 0 event(s) from the wrapper.
(pick a date anywhere on this page)