feat: add mobile bottom navigation bar

Replace cramped horizontal nav on mobile with a fixed bottom tab bar
for thumb-friendly navigation. The header nav is now hidden on mobile
(<768px) and the bottom nav provides Home, Shop, About, and Contact
links with icons.

- Add mobile_bottom_nav component with icon + label nav items
- Active page has accent-colored background highlight and larger icon
- Add shadow to lift nav visually off the page
- Update all page templates with bottom padding and bottom nav
- Remove CSS rule that was overriding Tailwind's hidden class
- Responsive header padding (tighter on mobile)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2026-01-20 22:03:42 +00:00
parent 9c81f9511d
commit 4b22bb4a4b
9 changed files with 189 additions and 19 deletions

View File

@ -413,10 +413,8 @@
} }
} }
/* Shop nav display */ /* Shop nav display - hidden on mobile, flex on md+ (via Tailwind classes in component) */
.shop-nav { /* Note: Removed explicit display:flex here as it overrides Tailwind's hidden class */
display: flex;
}
/* ============================================= /* =============================================
STANDALONE COMPONENTS (Context-independent) STANDALONE COMPONENTS (Context-independent)

View File

@ -1,4 +1,4 @@
<div class="shop-container min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"> <div class="shop-container min-h-screen pb-20 md:pb-0" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
<.skip_link /> <.skip_link />
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
@ -24,4 +24,6 @@
<.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} />
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
<.mobile_bottom_nav active_page="about" mode={@mode} />
</div> </div>

View File

@ -1,4 +1,4 @@
<div class="shop-container min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"> <div class="shop-container min-h-screen pb-20 md:pb-0" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
<.skip_link /> <.skip_link />
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
@ -16,4 +16,6 @@
<.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} />
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
<.mobile_bottom_nav active_page="cart" mode={@mode} />
</div> </div>

View File

@ -1,4 +1,4 @@
<div class="shop-container min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"> <div class="shop-container min-h-screen pb-20 md:pb-0" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
<.skip_link /> <.skip_link />
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
@ -32,4 +32,6 @@
<.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} />
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
<.mobile_bottom_nav active_page="collection" mode={@mode} />
</div> </div>

View File

@ -1,4 +1,4 @@
<div class="shop-container min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"> <div class="shop-container min-h-screen pb-20 md:pb-0" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
<.skip_link /> <.skip_link />
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
@ -44,4 +44,6 @@
<.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} />
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
<.mobile_bottom_nav active_page="contact" mode={@mode} />
</div> </div>

View File

@ -1,4 +1,4 @@
<div class="shop-container min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"> <div class="shop-container min-h-screen pb-20 md:pb-0" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
<.skip_link /> <.skip_link />
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
@ -40,4 +40,6 @@
<.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} />
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
<.mobile_bottom_nav active_page="home" mode={@mode} />
</div> </div>

View File

@ -1,4 +1,4 @@
<div class="shop-container min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"> <div class="shop-container min-h-screen pb-20 md:pb-0" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
<.skip_link /> <.skip_link />
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
@ -41,4 +41,6 @@
<.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> <.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} />
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
<.mobile_bottom_nav active_page="pdp" mode={@mode} />
</div> </div>

View File

@ -53,6 +53,164 @@ defmodule SimpleshopThemeWeb.ShopComponents do
""" """
end end
@doc """
Renders a mobile bottom navigation bar.
This component provides thumb-friendly navigation for mobile devices,
following modern UX best practices. It's hidden on larger screens where
the standard header navigation is used.
## Attributes
* `active_page` - Required. The current page identifier (e.g., "home", "collection", "about", "contact").
* `mode` - Optional. Either `:live` (default) for real navigation or
`:preview` for theme preview mode with phx-click handlers.
* `cart_count` - Optional. Number of items in cart for badge display. Default: 0.
## Examples
<.mobile_bottom_nav active_page="home" />
<.mobile_bottom_nav active_page="collection" mode={:preview} />
"""
attr :active_page, :string, required: true
attr :mode, :atom, default: :live
attr :cart_count, :integer, default: 0
def mobile_bottom_nav(assigns) do
~H"""
<nav
class="mobile-bottom-nav md:hidden"
style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 100; background-color: var(--t-surface-raised); border-top: 1px solid var(--t-border-default); box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); padding-bottom: env(safe-area-inset-bottom, 0px);"
aria-label="Main navigation"
>
<ul class="flex justify-around items-center h-16" style="margin: 0; padding: 0; list-style: none;">
<.mobile_nav_item
icon={:home}
label="Home"
page="home"
href="/"
active_page={@active_page}
mode={@mode}
/>
<.mobile_nav_item
icon={:shop}
label="Shop"
page="collection"
href="/collections/all"
active_page={@active_page}
active_pages={["collection", "pdp"]}
mode={@mode}
/>
<.mobile_nav_item
icon={:about}
label="About"
page="about"
href="/about"
active_page={@active_page}
mode={@mode}
/>
<.mobile_nav_item
icon={:contact}
label="Contact"
page="contact"
href="/contact"
active_page={@active_page}
mode={@mode}
/>
</ul>
</nav>
"""
end
attr :icon, :atom, required: true
attr :label, :string, required: true
attr :page, :string, required: true
attr :href, :string, required: true
attr :active_page, :string, required: true
attr :active_pages, :list, default: nil
attr :mode, :atom, default: :live
defp mobile_nav_item(assigns) do
active_pages = assigns.active_pages || [assigns.page]
is_current = assigns.active_page in active_pages
assigns = assign(assigns, :is_current, is_current)
~H"""
<li class="flex-1">
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page={@page}
class="flex flex-col items-center justify-center gap-1 py-2 mx-1 rounded-lg min-h-[56px]"
style={"color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))", else: "var(--t-text-secondary)"}; text-decoration: none; font-weight: #{if @is_current, do: "600", else: "500"}; background-color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1)", else: "transparent"};"}
aria-current={if @is_current, do: "page", else: nil}
>
<.nav_icon icon={@icon} size={if @is_current, do: "w-6 h-6", else: "w-5 h-5"} />
<span class="text-xs">{@label}</span>
</a>
<% else %>
<a
href={@href}
class="flex flex-col items-center justify-center gap-1 py-2 mx-1 rounded-lg min-h-[56px]"
style={"color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))", else: "var(--t-text-secondary)"}; text-decoration: none; font-weight: #{if @is_current, do: "600", else: "500"}; background-color: #{if @is_current, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1)", else: "transparent"};"}
aria-current={if @is_current, do: "page", else: nil}
>
<.nav_icon icon={@icon} size={if @is_current, do: "w-6 h-6", else: "w-5 h-5"} />
<span class="text-xs">{@label}</span>
</a>
<% end %>
</li>
"""
end
defp nav_icon(%{icon: :home} = assigns) do
assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end)
~H"""
<svg class={@size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
"""
end
defp nav_icon(%{icon: :shop} = assigns) do
assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end)
~H"""
<svg class={@size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
"""
end
defp nav_icon(%{icon: :about} = assigns) do
assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end)
~H"""
<svg class={@size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
"""
end
defp nav_icon(%{icon: :contact} = assigns) do
assigns = assign_new(assigns, :size, fn -> "w-5 h-5" end)
~H"""
<svg class={@size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
"""
end
@doc """ @doc """
Renders the search modal overlay. Renders the search modal overlay.
@ -250,8 +408,8 @@ defmodule SimpleshopThemeWeb.ShopComponents do
def shop_header(assigns) do def shop_header(assigns) do
~H""" ~H"""
<header <header
class="shop-header" class="shop-header px-2 py-2 sm:px-4 sm:py-3 md:px-8 md:py-4"
style="background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); padding: 1rem 2rem; display: flex; align-items: center;" style="background-color: var(--t-surface-raised); border-bottom: 1px solid var(--t-border-default); display: flex; align-items: center;"
> >
<%= if @theme_settings.header_background_enabled && @header_image do %> <%= if @theme_settings.header_background_enabled && @header_image do %>
<div style={header_background_style(@theme_settings, @header_image)} /> <div style={header_background_style(@theme_settings, @header_image)} />
@ -266,7 +424,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
/> />
</div> </div>
<nav class="shop-nav hidden md:flex" style="gap: 1.5rem; position: relative; z-index: 1;"> <nav class="shop-nav hidden md:flex md:gap-6" style="position: relative; z-index: 1;">
<%= if @mode == :preview do %> <%= if @mode == :preview do %>
<.nav_item label="Home" page="home" active_page={@active_page} mode={:preview} /> <.nav_item label="Home" page="home" active_page={@active_page} mode={:preview} />
<.nav_item label="Shop" page="collection" active_page={@active_page} mode={:preview} active_pages={["collection", "pdp"]} /> <.nav_item label="Shop" page="collection" active_page={@active_page} mode={:preview} active_pages={["collection", "pdp"]} />
@ -280,7 +438,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<% end %> <% end %>
</nav> </nav>
<div class="shop-actions flex items-center gap-1" style="position: relative; z-index: 1;"> <div class="shop-actions flex items-center" style="position: relative; z-index: 1;">
<button <button
type="button" type="button"
class="header-icon-btn w-9 h-9 flex items-center justify-center transition-all" class="header-icon-btn w-9 h-9 flex items-center justify-center transition-all"
@ -1456,7 +1614,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
## Attributes ## Attributes
* `title` - Optional. Form heading. Defaults to "Send a message". * `title` - Optional. Form heading. Defaults to "Send a message".
* `email` - Optional. If provided, displays "or email [email]" below the title. * `email` - Optional. If provided, displays "Email me: [email]" below the title.
## Examples ## Examples
@ -1480,7 +1638,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<%= if @email || @response_time do %> <%= if @email || @response_time do %>
<div class="mb-6 text-sm space-y-1" style="color: var(--t-text-secondary);"> <div class="mb-6 text-sm space-y-1" style="color: var(--t-text-secondary);">
<%= if @email do %> <%= if @email do %>
<p>or email <a href={"mailto:#{@email}"} style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));"><%= @email %></a></p> <p>Email me: <a href={"mailto:#{@email}"} style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));"><%= @email %></a></p>
<% end %> <% end %>
<%= if @response_time do %> <%= if @response_time do %>
<p><%= @response_time %></p> <p><%= @response_time %></p>
@ -1677,7 +1835,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<.newsletter_card variant={:inline} /> <.newsletter_card variant={:inline} />
""" """
attr :title, :string, default: "Newsletter" attr :title, :string, default: "Newsletter"
attr :description, :string, default: "New designs and updates. No spam." attr :description, :string, default: "Get new designs and updates in your inbox. No spam, I promise."
attr :button_text, :string, default: "Subscribe" attr :button_text, :string, default: "Subscribe"
attr :variant, :atom, default: :card attr :variant, :atom, default: :card
@ -1752,7 +1910,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<.social_links_card /> <.social_links_card />
<.social_links_card title="Elsewhere" links={[%{platform: :instagram, url: "https://instagram.com/example", label: "Instagram"}]} /> <.social_links_card title="Elsewhere" links={[%{platform: :instagram, url: "https://instagram.com/example", label: "Instagram"}]} />
""" """
attr :title, :string, default: "Find me online" attr :title, :string, default: "Find me on"
attr :links, :list, default: [ attr :links, :list, default: [
%{platform: :instagram, url: "#", label: "Instagram"}, %{platform: :instagram, url: "#", label: "Instagram"},
%{platform: :pinterest, url: "#", label: "Pinterest"} %{platform: :pinterest, url: "#", label: "Pinterest"}

View File

@ -100,7 +100,7 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="shop-container min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);"> <div class="shop-container min-h-screen pb-20 md:pb-0" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
<SimpleshopThemeWeb.ShopComponents.skip_link /> <SimpleshopThemeWeb.ShopComponents.skip_link />
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
@ -158,6 +158,8 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do
<SimpleshopThemeWeb.ShopComponents.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} /> <SimpleshopThemeWeb.ShopComponents.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} />
<SimpleshopThemeWeb.ShopComponents.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} /> <SimpleshopThemeWeb.ShopComponents.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
<SimpleshopThemeWeb.ShopComponents.mobile_bottom_nav active_page="collection" mode={@mode} />
</div> </div>
""" """
end end