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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2026-03-14]

### Added

- Added `odyssey_product_picker` component — a form-integrated product selector with "All" / "Specific" toggle and per-product checkboxes. Expects products as `%{id, name, color}` atom-keyed maps. Auto-extracts `selected_ids` from the form field value (supports maps, structs, and Ecto changesets) when not explicitly provided.
- Added `OdysseyProductPicker` JS hook for client-side checkbox ↔ hidden field sync.
- Added `odyssey_select` component — a generic dropdown LiveComponent with search/filter.
- Added product picker and select demos to the showcase app with LiveView tests.

## [2026-03-05]

### Changed
Expand Down
62 changes: 62 additions & 0 deletions assets/js/odyssey.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,68 @@ const OdysseyHooks = {
})
}
}
},
OdysseySelect: {
mounted () {
this.positionDropdown()
},
updated () {
this.positionDropdown()
},
positionDropdown () {
const dropdown = this.el.querySelector('[data-dropdown]')
if (!dropdown) return

const button = this.el.querySelector('button')
const buttonRect = button.getBoundingClientRect()
const dropdownHeight = dropdown.offsetHeight
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - buttonRect.bottom
const spaceAbove = buttonRect.top

if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
dropdown.style.bottom = '100%'
dropdown.style.top = 'auto'
dropdown.style.marginBottom = '0.5rem'
dropdown.style.marginTop = '0'
} else {
dropdown.style.top = '100%'
dropdown.style.bottom = 'auto'
dropdown.style.marginTop = '0.5rem'
dropdown.style.marginBottom = '0'
}
}
},
OdysseyProductPicker: {
mounted () {
this.el.addEventListener('click', (event) => {
if (event.target.classList.contains('product-picker-checkbox')) {
event.stopPropagation()
}
})

this.el.addEventListener('change', (event) => {
if (event.target.classList.contains('product-picker-checkbox')) {
event.preventDefault()
event.stopPropagation()

const checkboxes = this.el.querySelectorAll('.product-picker-checkbox:checked')
const selectedIds = Array.from(checkboxes).map(cb => cb.dataset.productId)

const hiddenInput = this.el.querySelector('input[type="hidden"]')
hiddenInput.value = selectedIds.join(',')
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }))
}
})

this.handleEvent('update-product-selection', ({ field_id, value }) => {
const hiddenInput = document.getElementById(field_id)
if (hiddenInput) {
hiddenInput.value = value
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }))
}
})
}
}
}

Expand Down
33 changes: 32 additions & 1 deletion demo/lib/demo_web/live/odyssey_showcase_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ defmodule DemoWeb.OdysseyShowcaseLive do
"discount" => "",
"percentage" => "",
"start_date" => "",
"expiration_date" => ""
"expiration_date" => "",
"whitelisted_products" => ""
}

socket =
Expand All @@ -32,6 +33,7 @@ defmodule DemoWeb.OdysseyShowcaseLive do
|> assign(:form_data, form_data)
|> assign(:form, to_form(form_data, as: "form"))
|> assign(:sample_activities, sample_activities())
|> assign(:sample_products, sample_products())

{:ok, socket}
end
Expand Down Expand Up @@ -558,6 +560,26 @@ defmodule DemoWeb.OdysseyShowcaseLive do

<.odyssey_divider />

<div>
<h3 class="text-lg font-medium mb-4">Product Picker Component</h3>
<p class="text-gray-600 mb-4">
Select products with a toggle between "All" and "Specific" modes.
When "Specific" is selected, checkboxes appear for each product.
</p>

<.form for={@form} phx-change="validate" id="product-picker-form">
<.odyssey_product_picker
field={@form[:whitelisted_products]}
products={@sample_products}
all_label="All Products"
specific_label="Specific Products"
label="Apply to"
/>
</.form>
</div>

<.odyssey_divider />

<div>
<h3 class="text-lg font-medium mb-4">Activity Picker Component</h3>
<p class="text-gray-600 mb-4">
Expand Down Expand Up @@ -621,6 +643,15 @@ defmodule DemoWeb.OdysseyShowcaseLive do
"""
end

defp sample_products do
[
%{id: "prod_1", name: "Wine Tasting", color: "#8B5CF6"},
%{id: "prod_2", name: "Cooking Class", color: "#F59E0B"},
%{id: "prod_3", name: "City Tour", color: "#10B981"},
%{id: "prod_4", name: "Sunset Cruise", color: "#3B82F6"}
]
end

defp sample_activities do
[
%{
Expand Down
87 changes: 87 additions & 0 deletions demo/test/demo_web/live/odyssey_showcase_live_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
defmodule DemoWeb.OdysseyShowcaseLiveTest do
use DemoWeb.ConnCase

import Phoenix.LiveViewTest

describe "product picker" do
test "renders product picker in forms tab", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")

html =
view
|> element("[phx-click='change_tab'][phx-value-tab='forms']")
|> render_click()

assert html =~ "Product Picker Component"
assert html =~ "product-picker-form"
assert html =~ "All Products"
assert html =~ "Specific Products"
end

test "product picker starts in 'all' mode by default", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")

view
|> element("[phx-click='change_tab'][phx-value-tab='forms']")
|> render_click()

html = render(view)

# In "all" mode, individual product checkboxes should not be visible
refute html =~ "Wine Tasting"
refute html =~ "Cooking Class"
end

test "toggling to 'specific' shows product checkboxes", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")

view
|> element("[phx-click='change_tab'][phx-value-tab='forms']")
|> render_click()

html =
view
|> element("button[value='specific']")
|> render_click()

assert html =~ "Wine Tasting"
assert html =~ "Cooking Class"
assert html =~ "City Tour"
assert html =~ "Sunset Cruise"
end

test "toggling back to 'all' hides product checkboxes", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")

view
|> element("[phx-click='change_tab'][phx-value-tab='forms']")
|> render_click()

# Toggle to specific first
view
|> element("button[value='specific']")
|> render_click()

# Toggle back to all
html =
view
|> element("button[value='all']")
|> render_click()

refute html =~ "Wine Tasting"
refute html =~ "Cooking Class"
end

test "product picker renders with label", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")

html =
view
|> element("[phx-click='change_tab'][phx-value-tab='forms']")
|> render_click()

assert html =~ "Apply to"
end
end
end

2 changes: 2 additions & 0 deletions lib/peek_app_sdk/ui/odyssey.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ defmodule PeekAppSDK.UI.Odyssey do
defdelegate odyssey_icon(assigns), to: PeekAppSDK.UI.Odyssey.Icon
defdelegate odyssey_tabs(assigns), to: PeekAppSDK.UI.Odyssey.Tabs
defdelegate odyssey_toggle_button(assigns), to: PeekAppSDK.UI.Odyssey.ToggleButton
defdelegate odyssey_product_picker(assigns), to: PeekAppSDK.UI.Odyssey.ProductPicker
defdelegate odyssey_prefix_input(assigns), to: PeekAppSDK.UI.Odyssey.PrefixInput
defdelegate odyssey_date_picker(assigns), to: PeekAppSDK.UI.Odyssey.DatePicker
defdelegate odyssey_tooltip(assigns), to: PeekAppSDK.UI.Odyssey.Tooltip
defdelegate odyssey_select(assigns), to: PeekAppSDK.UI.Odyssey.Select
end
Loading
Loading