Packages
phoenix_kit
1.7.55
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit/dashboard/README.md
# PhoenixKit User Dashboard Tab System
A comprehensive, extensible navigation system for the PhoenixKit user dashboard that allows parent applications to add custom tabs with rich features.
## Features
- **Dynamic Tabs**: Configure tabs via config or register at runtime
- **Subtabs**: Hierarchical parent/child tab relationships with conditional visibility
- **Live Badges**: Real-time badge updates via PubSub
- **Badge Types**: Count, dot, status, "new", and custom text badges
- **Grouping**: Organize tabs into sections with headers and dividers
- **Conditional Visibility**: Show/hide tabs based on roles or custom logic
- **Attention Animations**: Pulse, bounce, shake, glow effects
- **Presence Tracking**: Show user counts per tab
- **Path Matching**: Flexible active state detection (exact, prefix, regex, custom)
- **Mobile Support**: Responsive bottom navigation and FAB menu
## Quick Start
### Using the Mix Generator (Recommended)
The easiest way to add tabs is using the Mix generator:
```bash
# Add a History tab to Farm Management category
mix phoenix_kit.gen.dashboard_tab "Farm Management" "History" \
--url="/dashboard/history" \
--icon="hero-chart-bar" \
--category-icon="hero-cube"
# Add a Settings tab to Account category
mix phoenix_kit.gen.dashboard_tab "Account" "Settings" \
--url="/dashboard/settings" \
--icon="hero-cog-6-tooth" \
--description="Manage your account settings"
```
This will automatically add the tab configuration to your `config/config.exs`.
### Configure Categories in config.exs
For category-based organization (similar to admin dashboard):
```elixir
config :phoenix_kit, :user_dashboard_categories, [
%{
title: "Farm Management",
icon: "hero-cube",
tabs: [
%{
title: "Printers",
url: "/dashboard/printers",
icon: "hero-printer",
description: "Manage your 3D printers"
},
%{
title: "History",
url: "/dashboard/history",
icon: "hero-chart-bar"
}
]
},
%{
title: "Account",
icon: "hero-user",
tabs: [
%{title: "Settings", url: "/dashboard/settings", icon: "hero-cog-6-tooth"}
]
}
]
```
### Configure Flat Tabs in config.exs
For simpler flat tab configuration:
```elixir
config :phoenix_kit, :user_dashboard_tabs, [
%{
id: :orders,
label: "My Orders",
icon: "hero-shopping-bag",
path: "/dashboard/orders",
priority: 100
},
%{
id: :notifications,
label: "Notifications",
icon: "hero-bell",
path: "/dashboard/notifications",
priority: 200,
badge: %{type: :count, value: 0, color: :error}
}
]
```
### Register Tabs at Runtime (Optional)
```elixir
# In your application startup or a module
PhoenixKit.Dashboard.register_tabs(:my_app, [
%{
id: :printers,
label: "Printers",
icon: "hero-cube",
path: "/dashboard/printers",
priority: 150,
badge: %{
type: :count,
subscribe: {"farm:stats", fn msg -> msg.printing_count end}
}
}
])
```
### Update Badges Live
```elixir
# From anywhere in your app
PhoenixKit.Dashboard.update_badge(:notifications, 5)
PhoenixKit.Dashboard.update_badge(:printers, count: 3, color: :warning)
# Increment/decrement
PhoenixKit.Dashboard.increment_badge(:notifications)
PhoenixKit.Dashboard.decrement_badge(:notifications)
```
### Trigger Attention
```elixir
# Make a tab pulse to draw attention
PhoenixKit.Dashboard.set_attention(:alerts, :pulse)
# Clear attention
PhoenixKit.Dashboard.clear_attention(:alerts)
```
## Tab Configuration
### Basic Tab
```elixir
%{
id: :orders, # Required: Unique atom identifier
label: "Orders", # Required: Display text
path: "/dashboard/orders", # Required: URL path
icon: "hero-shopping-bag", # Optional: Heroicon name
priority: 100 # Optional: Sort order (default: 500)
}
```
### Tab Path Format
**Important:** Tab paths should be specified WITHOUT the PhoenixKit URL prefix.
```elixir
# ✅ CORRECT - relative path without prefix
path: "/dashboard/orders"
# ❌ WRONG - don't include the phoenix_kit prefix
path: "/phoenix_kit/dashboard/orders"
```
PhoenixKit automatically normalizes paths for matching:
- Strips the configured URL prefix (default: `/phoenix_kit`)
- Strips locale prefixes (e.g., `/en/dashboard/orders` → `/dashboard/orders`)
- Handles trailing slashes consistently
This means your tab will correctly highlight regardless of:
- What URL prefix is configured
- Whether the user has a locale in the URL
- Whether there's a trailing slash
### Full Options
```elixir
%{
id: :printers,
label: "Printers",
icon: "hero-cube",
path: "/dashboard/printers",
priority: 150,
group: :farm, # Group ID for organization
match: :prefix, # :exact, :prefix, {:regex, ~r/.../}, or function
visible: fn scope -> scope.user.has_farm? end, # Conditional visibility
badge: %{
type: :count, # :count, :dot, :status, :new, :text
value: 0,
color: :primary, # :primary, :secondary, :accent, :info, :success, :warning, :error
max: 99, # Shows "99+" for higher values
pulse: true, # Enable pulse animation
subscribe: {"topic", :key} # PubSub subscription for live updates
},
tooltip: "View all printers",
external: false, # External link (opens in new tab)
new_tab: false, # Open in new browser tab
attention: nil # :pulse, :bounce, :shake, :glow
}
```
## Tab Groups
Organize tabs into labeled sections:
```elixir
config :phoenix_kit, :user_dashboard_tab_groups, [
%{id: :main, label: nil, priority: 100},
%{id: :farm, label: "Farm Management", priority: 200, icon: "hero-cube", collapsible: true},
%{id: :account, label: "Account", priority: 900}
]
```
Then assign tabs to groups:
```elixir
%{id: :printers, label: "Printers", path: "/dashboard/printers", group: :farm}
```
## Subtabs
Create hierarchical navigation with parent/child relationships:
### Basic Subtabs
```elixir
config :phoenix_kit, :user_dashboard_tabs, [
# Parent tab
%{
id: :orders,
label: "Orders",
icon: "hero-shopping-bag",
path: "/dashboard/orders",
priority: 100,
subtab_display: :when_active # Show subtabs only when parent is active
},
# Subtabs
%{
id: :pending_orders,
label: "Pending",
path: "/dashboard/orders/pending",
priority: 110,
parent: :orders # Links this tab to the parent
},
%{
id: :completed_orders,
label: "Completed",
path: "/dashboard/orders/completed",
priority: 120,
parent: :orders
},
%{
id: :cancelled_orders,
label: "Cancelled",
path: "/dashboard/orders/cancelled",
priority: 130,
parent: :orders
}
]
```
> **Note:** The user dashboard supports one level of nesting (parent → subtab). The admin sidebar supports two levels of nesting (parent → subtab → sub-subtab) for cases like Settings > Media > Dimensions.
### Subtab Display Options
- `:when_active` (default) - Subtabs only appear when the parent tab is active
- `:always` - Subtabs are always visible
```elixir
# Always show subtabs
%{
id: :settings,
label: "Settings",
path: "/dashboard/settings",
subtab_display: :always
}
```
### Redirect to First Subtab
When `redirect_to_first_subtab: true` is set on a parent tab, clicking the parent tab navigates to the first subtab instead of the parent's own path. This is useful when the parent tab serves as a container/category and the first subtab is the default landing page.
```elixir
%{
id: :orders,
label: "Orders",
path: "/dashboard/orders", # This path won't be used for navigation
redirect_to_first_subtab: true, # Clicking "Orders" goes to first subtab
subtab_display: :when_active
}
# First subtab (priority 110) becomes the landing page
%{id: :pending, label: "Pending", path: "/dashboard/orders/pending", parent: :orders, priority: 110}
%{id: :completed, label: "Completed", path: "/dashboard/orders/completed", parent: :orders, priority: 120}
```
With this config, clicking "Orders" navigates to `/dashboard/orders/pending`.
### Parent Tab Highlighting
By default, when a subtab is active, only the subtab is highlighted (not the parent). This behavior can be changed with `highlight_with_subtabs`:
```elixir
%{
id: :orders,
label: "Orders",
path: "/dashboard/orders",
highlight_with_subtabs: true # Also highlight parent when subtab is active (default: false)
}
```
- `highlight_with_subtabs: false` (default) - Only the active subtab is highlighted
- `highlight_with_subtabs: true` - Both parent and active subtab are highlighted
### Subtab Customization
Subtabs support customizable styling for indent, icon size, text size, and entry animations.
#### Style Options
Set these on the **parent tab** to apply to all its subtabs, or on **individual subtabs** to override:
```elixir
%{
id: :orders,
label: "Orders",
path: "/dashboard/orders",
subtab_display: :when_active,
# Style options for subtabs (applied to children)
subtab_indent: "pl-12", # Tailwind padding-left class (default: "pl-4")
subtab_icon_size: "w-3 h-3", # Icon size classes (default: "w-4 h-4")
subtab_text_size: "text-xs", # Text size class (default: "text-sm")
subtab_animation: :slide # Animation: :none, :slide, :fade, :collapse
}
```
#### Per-Subtab Overrides
Individual subtabs can override the parent's styling:
```elixir
# Parent with default styling
%{id: :settings, label: "Settings", path: "/dashboard/settings", subtab_display: :always},
# Subtab with custom styling (overrides parent)
%{
id: :advanced_settings,
label: "Advanced",
path: "/dashboard/settings/advanced",
parent: :settings,
subtab_indent: "pl-14",
subtab_text_size: "text-xs font-medium"
}
```
#### Global Defaults
Set global subtab styling defaults in your config:
```elixir
config :phoenix_kit,
dashboard_subtab_style: [
indent: "pl-4", # Default indent (Tailwind class)
icon_size: "w-4 h-4", # Default icon size
text_size: "text-sm", # Default text size
animation: :none # Default animation
]
```
#### Indent Value Formats
The `indent` option (for `subtab_indent` or `dashboard_subtab_style.indent`) supports multiple formats:
**Tailwind Classes (standard):**
```elixir
indent: "pl-4" # Default: ~16px / 1rem
indent: "pl-12" # 3rem
indent: "pl-6" # 1.5rem
```
**Inline CSS Values (for precise control):**
```elixir
indent: "1.5rem" # Converted to inline style
indent: "24px" # Pixels
indent: "2em" # Relative to font size
```
**Numeric Values:**
```elixir
indent: 24 # Integer treated as pixels → "padding-left: 24px"
indent: 1.5 # Float treated as rem → "padding-left: 1.5rem"
```
> **Note:** Using inline CSS values avoids Tailwind's CSS purging issues. If you use a
> non-standard Tailwind class like `"pl-7"` or `"pl-11"`, it may not be included in your
> compiled CSS unless you add it to your Tailwind safelist. A warning will be logged
> for potentially missing Tailwind classes.
#### Style Cascade
Styles are resolved in this order (first non-nil wins):
1. Subtab's own `subtab_*` fields
2. Parent tab's `subtab_*` fields
3. Global `dashboard_subtab_style` config
4. Hardcoded defaults
#### Animation Options
- `:none` - No animation (default)
- `:slide` - Slides in from the left
- `:fade` - Fades in
- `:collapse` - Expands from collapsed state
Animations play when subtabs become visible (when navigating to parent or subtab).
### Dynamic Subtab Registration
```elixir
# Register parent and subtabs at runtime
PhoenixKit.Dashboard.register_tabs(:my_app, [
%{id: :printers, label: "Printers", path: "/dashboard/printers", subtab_display: :when_active},
%{id: :active_printers, label: "Active", path: "/dashboard/printers/active", parent: :printers},
%{id: :idle_printers, label: "Idle", path: "/dashboard/printers/idle", parent: :printers}
])
```
### Subtab API
```elixir
# Get all subtabs for a parent
PhoenixKit.Dashboard.get_subtabs(:orders)
# => [%Tab{id: :pending_orders, ...}, %Tab{id: :completed_orders, ...}]
# Get only top-level tabs
PhoenixKit.Dashboard.get_top_level_tabs()
# Check if a tab has subtabs
PhoenixKit.Dashboard.has_subtabs?(:orders)
# => true
# Check if a tab is a subtab
PhoenixKit.Dashboard.subtab?(tab)
# => true
# Check if subtabs should be shown
PhoenixKit.Dashboard.show_subtabs?(parent_tab, is_active)
```
### Subtab Behavior Reference
This section documents how subtab options interact with each other.
#### Option Summary
| Option | Default | Description |
|--------|---------|-------------|
| `parent` | `nil` | Links this tab as a subtab of the specified parent |
| `subtab_display` | `:when_active` | When to show subtabs: `:when_active` or `:always` |
| `highlight_with_subtabs` | `false` | Whether parent highlights when a subtab is active |
| `redirect_to_first_subtab` | `false` | Clicking parent navigates to first subtab instead |
| `match` | `:prefix` | Path matching strategy: `:exact`, `:prefix`, `{:regex, ~r/...}`, or function |
#### Common Patterns
**Pattern 1: Category parent with default subtab landing page**
The parent tab acts as a category header. Clicking it goes to the first subtab. Both parent and active subtab are highlighted.
```elixir
# Parent - acts as category, redirects to first subtab
%{
id: :history,
label: "History",
path: "/dashboard/history",
redirect_to_first_subtab: true, # Click goes to /dashboard/history/timeline
highlight_with_subtabs: true, # Parent stays highlighted
subtab_display: :when_active
}
# Subtabs
%{id: :timeline, label: "Timeline", path: "/dashboard/history/timeline", parent: :history, priority: 100}
%{id: :stats, label: "Stats", path: "/dashboard/history/stats", parent: :history, priority: 200}
```
**Pattern 2: Parent has its own content, subtabs are secondary**
The parent tab has its own page. Subtabs provide additional views. Only the active item highlights.
```elixir
# Parent - has its own content at /dashboard/orders
%{
id: :orders,
label: "Orders",
path: "/dashboard/orders",
match: :exact, # Only highlight when exactly on /dashboard/orders
highlight_with_subtabs: false, # Don't highlight parent when on subtab
subtab_display: :when_active
}
# Subtabs
%{id: :pending, label: "Pending", path: "/dashboard/orders/pending", parent: :orders}
%{id: :completed, label: "Completed", path: "/dashboard/orders/completed", parent: :orders}
```
**Pattern 3: Always-visible subtabs (settings menu)**
Subtabs are always visible regardless of which is active.
```elixir
%{
id: :settings,
label: "Settings",
path: "/dashboard/settings",
subtab_display: :always, # Always show subtabs
redirect_to_first_subtab: true
}
%{id: :profile, label: "Profile", path: "/dashboard/settings/profile", parent: :settings}
%{id: :security, label: "Security", path: "/dashboard/settings/security", parent: :settings}
```
#### How Options Interact
**`match` and `highlight_with_subtabs`:**
- With `match: :prefix` (default), the parent highlights for `/dashboard/orders`, `/dashboard/orders/pending`, etc.
- With `match: :exact`, the parent only highlights for exactly `/dashboard/orders`
- `highlight_with_subtabs: true` overrides `match: :exact` to also highlight when subtabs are active
**`redirect_to_first_subtab` and parent path:**
- When `true`, the parent's `path` is used for sidebar display but clicking navigates to the first subtab
- The first subtab is determined by lowest `priority` value
- If no subtabs exist, falls back to parent's path
**`subtab_display` and visibility:**
- `:when_active` - Subtabs appear only when parent or any subtab is the current page
- `:always` - Subtabs always appear in the sidebar
#### Debugging Tips
If subtabs aren't behaving as expected:
1. **Parent not highlighting?** Check `highlight_with_subtabs` and `match` settings
2. **Subtabs not showing?** Verify `subtab_display` and that `parent` ID matches
3. **Wrong tab active?** Check path normalization - paths should not include URL prefix
4. **Click goes to wrong place?** Check `redirect_to_first_subtab` and subtab priorities
## Badge Types
### Count Badge
```elixir
%{type: :count, value: 5}
%{type: :count, value: 150, max: 99} # Shows "99+"
%{type: :count, value: 3, color: :error, pulse: true}
```
### Dot Badge
```elixir
%{type: :dot, color: :success} # Green dot
%{type: :dot, color: :warning, pulse: true} # Pulsing yellow dot
```
### Status Badge
```elixir
%{type: :status, value: :online, color: :success}
%{type: :status, value: :busy, color: :warning}
```
### New Badge
```elixir
%{type: :new}
%{type: :new, color: :accent}
```
### Text Badge
```elixir
%{type: :text, value: "Beta"}
%{type: :text, value: "Pro", color: :accent}
```
### Compound Badge
Display multiple values with different colors in a single badge. Useful for showing
status breakdowns like "10 success / 5 pending / 2 error".
```elixir
%{
type: :compound,
segments: [
%{value: 10, color: :success},
%{value: 5, color: :warning},
%{value: 2, color: :error}
],
compound_style: :text, # :text, :blocks, or :dots
separator: "/" # Separator for :text style
}
```
#### Styles
**Text Style (default):** Colored numbers with separator
```
10 / 5 / 2
```
**Blocks Style:** Colored background pills side by side
```
[10][5][2]
```
**Dots Style:** Colored dots with numbers
```
● 10 ● 5 ● 2
```
#### With Labels
```elixir
segments: [
%{value: 10, color: :success, label: "done"},
%{value: 5, color: :warning, label: "pending"}
]
# Displays: "10 done / 5 pending"
```
#### Hide Zero Segments
```elixir
%{
type: :compound,
segments: [
%{value: 10, color: :success},
%{value: 0, color: :error}
],
hide_zero_segments: true
}
# Only shows: "10"
```
#### Context-Aware Compound Badge
For compound badges that load segment data per-context:
```elixir
alias PhoenixKit.Dashboard.Badge
# Loader returns list of segments for current organization
Badge.compound_context(:organization, {MyApp.Tasks, :get_status_counts},
style: :blocks,
hide_zero_segments: true
)
# In MyApp.Tasks
def get_status_counts(org) do
[
%{value: count_completed(org.uuid), color: :success},
%{value: count_pending(org.uuid), color: :warning},
%{value: count_overdue(org.uuid), color: :error}
]
end
```
## Live Badge Updates via PubSub
Badges can subscribe to PubSub topics for automatic updates:
```elixir
# Using a key from the message
badge: %{
type: :count,
subscribe: {"user:#{user_uuid}:notifications", :unread_count}
}
# Using a function to extract the value
badge: %{
type: :count,
subscribe: {"farm:stats", fn msg -> msg.printing_count end}
}
```
When you broadcast to the topic, the badge updates automatically:
```elixir
Phoenix.PubSub.broadcast(MyApp.PubSub, "farm:stats", %{printing_count: 3})
```
## Context-Aware Badges
For badges that show different values per user, organization, or other context (e.g., "alert count for THIS user's farm"), use context-aware badges. These badges:
- Store values per-context in socket assigns (not globally)
- Load initial values using a loader function
- Subscribe to context-specific PubSub topics
### Basic Usage
```elixir
%{
id: :alerts,
label: "Alerts",
icon: "hero-bell-alert",
path: "/dashboard/alerts",
badge: %{
type: :count,
color: :error,
context_key: :organization, # Which context selector this depends on
loader: {MyApp.Alerts, :count_for_org} # Called with current context
}
}
```
### With Live Updates
```elixir
%{
id: :printers,
label: "Printers",
path: "/dashboard/printers",
badge: %{
type: :count,
context_key: :farm,
loader: {MyApp.Farms, :printing_count},
# {uuid} is replaced with farm.uuid at runtime
subscribe: {"farm:{uuid}:stats", fn msg -> msg.printing_count end}
}
}
```
### Topic Placeholders
Subscribe topics support `{field}` placeholders that are resolved from the current context:
- `{uuid}` - context.uuid
- `{name}` - context.name
- Any field accessible on the context struct/map
Examples:
- `"org:{uuid}:alerts"` → `"org:550e8400-e29b-41d4-a716-446655440000:alerts"`
- `"farm:{uuid}:stats"` → `"farm:abc-def-123:stats"`
### Loader Function
The loader is called when the LiveView mounts to get the initial badge value:
```elixir
# Module function - called as MyApp.Alerts.count_for_org(context)
loader: {MyApp.Alerts, :count_for_org}
# Anonymous function
loader: fn context -> MyApp.Alerts.count_for_org(context.id) end
```
### Using Badge.context/3
For cleaner syntax, use the convenience constructor:
```elixir
alias PhoenixKit.Dashboard.Badge
# Badge that shows alert count for current organization
Badge.context(:organization, {MyApp.Alerts, :count_for_org}, color: :error)
# With live updates
Badge.context(:farm, {MyApp.Farms, :printing_count},
subscribe: "farm:{id}:stats",
color: :info
)
```
### Handling Live Updates
When a PubSub message arrives for a context-aware badge, use `update_context_badge/3`:
```elixir
def handle_info(%{printing_count: count}, socket) do
# Update the badge value in socket assigns (not global ETS)
{:noreply, update_context_badge(socket, :printers, count)}
end
```
### Reinitializing on Context Change
When the user switches context, call `reinit_context_badges/1` to reload all context badge values:
```elixir
def handle_info({:context_changed, :organization, new_org}, socket) do
socket =
socket
|> assign(:current_contexts_map, %{organization: new_org})
|> reinit_context_badges()
{:noreply, socket}
end
```
### How It Works
1. **At mount**: `init_dashboard_tabs/2` detects context-aware badges, calls their loaders, and merges values directly into the tab's badge struct
2. **Subscriptions**: Topic placeholders are resolved using the current context from `:current_contexts_map`
3. **Updates**: Use `update_context_badge/3` to update both `:context_badge_values` and the tab's badge value
4. **Rendering**: Badge component uses `tab.badge.value` which already has the merged context value
5. **Preservation**: Tab refresh handlers automatically restore context badge values to prevent overwriting by global updates
### Global vs Context-Aware
| Feature | Global Badge | Context-Aware Badge |
|---------|--------------|---------------------|
| Storage | ETS (shared) | Socket assigns (per-user) |
| Updates | Broadcast to all users | Per-subscription |
| Use case | Site-wide notifications | Per-org/farm/team data |
| Config | `value: 5` | `context_key: :org, loader: {...}` |
## Conditional Visibility
Use the `visible` field for non-permission conditional logic like feature flags or user data conditions. For access control, use the `permission` field instead — see [Permission System](ADMIN_README.md#permission-system).
```elixir
%{
id: :beta_feature,
label: "Beta Feature",
path: "/dashboard/beta",
visible: fn scope ->
scope.user.features["beta_enabled"] == true
end
}
```
## Presence Tracking
Track how many users are viewing each tab:
```elixir
# In your LiveView
def mount(_params, _session, socket) do
if connected?(socket) do
PhoenixKit.Dashboard.track_presence(socket, :orders)
end
{:ok, socket}
end
```
Configure presence options:
```elixir
config :phoenix_kit, :dashboard_presence,
enabled: true,
show_user_count: true,
show_user_names: false,
track_anonymous: false
```
## Attention Animations
Draw user attention to important tabs:
```elixir
# Available animations
PhoenixKit.Dashboard.set_attention(:alerts, :pulse) # Gentle pulsing
PhoenixKit.Dashboard.set_attention(:errors, :bounce) # Bouncing motion
PhoenixKit.Dashboard.set_attention(:urgent, :shake) # Shaking motion
PhoenixKit.Dashboard.set_attention(:new, :glow) # Glowing effect
# Clear attention
PhoenixKit.Dashboard.clear_attention(:alerts)
```
## LiveView Integration
### Using TabsInitializer (Recommended)
For automatic tab initialization with context-aware badge support, use the `TabsInitializer` hook:
```elixir
# In your router
live_session :dashboard,
on_mount: [
{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_authenticated_scope},
{PhoenixKitWeb.Dashboard.ContextProvider, :default},
{PhoenixKitWeb.Dashboard.TabsInitializer, :default} # Automatically initializes tabs
] do
live "/dashboard", DashboardLive.Index
live "/dashboard/orders", DashboardLive.Orders
end
```
The `TabsInitializer` hook:
- Runs after `ContextProvider` to ensure context data is available
- Normalizes `current_contexts_map` for legacy single-selector configurations
- Loads all tabs from the Registry with active state
- Initializes context-aware badge values via their loaders
- Subscribes to PubSub topics for live badge updates
- Sets up presence tracking
**Mount Options:**
- `:default` - Full initialization with presence tracking
- `:minimal` - Skip presence tracking for lower overhead
- `:badges_only` - Only initialize badge values
```elixir
# Example with minimal overhead
{PhoenixKitWeb.Dashboard.TabsInitializer, :minimal}
```
### Using LiveTabs Module
For more control, use the `LiveTabs` module directly in your LiveView:
```elixir
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
use PhoenixKitWeb.Components.Dashboard.LiveTabs
def mount(_params, _session, socket) do
socket =
socket
|> init_dashboard_tabs()
|> track_tab_presence(:my_tab)
{:ok, socket}
end
end
```
### Manual Integration
```elixir
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(PhoenixKit.PubSub, PhoenixKit.Dashboard.pubsub_topic())
end
{:ok, assign(socket, :dashboard_tabs, PhoenixKit.Dashboard.get_tabs())}
end
def handle_info({:tab_updated, _tab}, socket) do
{:noreply, assign(socket, :dashboard_tabs, PhoenixKit.Dashboard.get_tabs())}
end
def handle_info(:tabs_refreshed, socket) do
{:noreply, assign(socket, :dashboard_tabs, PhoenixKit.Dashboard.get_tabs())}
end
```
## API Reference
### PhoenixKit.Dashboard
```elixir
# Tab Registration
register_tabs(namespace, tabs) # Register tabs for a namespace
unregister_tabs(namespace) # Remove all tabs for a namespace
unregister_tab(tab_id) # Remove a specific tab
# Tab Retrieval
get_tabs(opts \\ []) # Get all tabs
get_tab(tab_id) # Get a specific tab
get_tabs_with_active(path, opts) # Get tabs with active state
# Groups
register_groups(groups) # Register tab groups
get_groups() # Get all groups
# Subtabs
get_subtabs(parent_id, opts) # Get subtabs for a parent
get_top_level_tabs(opts) # Get only top-level tabs
has_subtabs?(tab_id) # Check if tab has subtabs
subtab?(tab) # Check if tab is a subtab
show_subtabs?(tab, active) # Check if subtabs should be shown
# Badges
update_badge(tab_id, value) # Update a tab's badge
increment_badge(tab_id, amount) # Increment count badge
decrement_badge(tab_id, amount) # Decrement count badge
clear_badge(tab_id) # Clear a tab's badge
# Attention
set_attention(tab_id, animation) # Set attention animation
clear_attention(tab_id) # Clear attention animation
# Presence
track_presence(socket, tab_id) # Track user on a tab
get_viewer_count(tab_id) # Get viewer count
get_all_viewer_counts() # Get all viewer counts
# PubSub
subscribe() # Subscribe to tab updates
pubsub_topic() # Get the PubSub topic
```
### Helper Functions
```elixir
# Tab creation
PhoenixKit.Dashboard.new_tab(attrs) # Create a Tab struct
PhoenixKit.Dashboard.divider(opts) # Create a divider
PhoenixKit.Dashboard.group_header(opts) # Create a group header
# Badge creation
PhoenixKit.Dashboard.count_badge(value, opts)
PhoenixKit.Dashboard.dot_badge(opts)
PhoenixKit.Dashboard.status_badge(value, opts)
PhoenixKit.Dashboard.live_badge(topic, extractor, opts)
# Utilities
PhoenixKit.Dashboard.matches_path?(tab, path)
PhoenixKit.Dashboard.visible?(tab, scope)
```
## File Structure
```
lib/phoenix_kit/dashboard/
├── dashboard.ex # Main public API
├── tab.ex # Tab struct and logic (level, permission, dynamic_children)
├── badge.ex # Badge struct and logic
├── registry.ex # Tab registry GenServer (shared user + admin)
├── admin_tabs.ex # Default admin tab definitions (~50 tabs)
├── presence.ex # Presence tracking
├── README.md # This file (user dashboard)
└── ADMIN_README.md # Admin navigation documentation
lib/phoenix_kit_web/components/dashboard/
├── sidebar.ex # User dashboard sidebar component
├── admin_sidebar.ex # Admin sidebar component
├── tab_item.ex # Shared tab rendering component
├── badge.ex # Badge rendering component
└── live_tabs.ex # LiveView integration helpers
```
## Admin Navigation
The admin sidebar uses the same registry-driven system with additional features for permissions, module-enabled filtering, and dynamic children. See [ADMIN_README.md](ADMIN_README.md) for full documentation.
## Default Tabs
PhoenixKit provides these default tabs:
1. **Dashboard** (id: `:dashboard_home`, priority: 100)
- Path: `/dashboard`
- Always shown
2. **Settings** (id: `:dashboard_settings`, priority: 900)
- Path: `/dashboard/settings`
- Always shown
3. **My Tickets** (id: `:dashboard_tickets`, priority: 800)
- Path: `/dashboard/tickets`
- Only shown when Tickets module is enabled
Your custom tabs will be merged with these defaults based on priority.
## Examples
### FarmKeeper Example
```elixir
# config/config.exs
config :phoenix_kit, :user_dashboard_tabs, [
%{
id: :printers,
label: "Printers",
icon: "hero-cube",
path: "/dashboard",
priority: 100,
group: :farm,
match: :exact,
badge: %{
type: :count,
subscribe: {"farm:stats", fn msg -> msg.printing_count end}
}
},
%{
id: :history,
label: "History",
icon: "hero-chart-bar",
path: "/dashboard/history",
priority: 200,
group: :farm
},
%{
id: :farm_settings,
label: "Farm Settings",
icon: "hero-cog-6-tooth",
path: "/dashboard/farm-settings",
priority: 300,
group: :farm,
visible: fn scope -> scope.user.has_farm? end
}
]
config :phoenix_kit, :user_dashboard_tab_groups, [
%{id: :farm, label: "Farm Management", priority: 100, icon: "hero-cube"},
%{id: :account, label: "Account", priority: 900}
]
```
### E-commerce Example
```elixir
config :phoenix_kit, :user_dashboard_tabs, [
%{
id: :orders,
label: "Orders",
icon: "hero-shopping-bag",
path: "/dashboard/orders",
priority: 100,
badge: %{type: :count, value: 0}
},
%{
id: :wishlist,
label: "Wishlist",
icon: "hero-heart",
path: "/dashboard/wishlist",
priority: 200
},
%{
id: :reviews,
label: "Reviews",
icon: "hero-star",
path: "/dashboard/reviews",
priority: 300,
badge: %{type: :new}
},
%{
id: :addresses,
label: "Addresses",
icon: "hero-map-pin",
path: "/dashboard/addresses",
priority: 400
}
]
```
## Context Selector
For multi-tenant applications where users can switch between organizations, farms, teams, or workspaces.
### Configuration
```elixir
config :phoenix_kit, :dashboard_context_selector,
# Required: Function to load contexts for a user
loader: {MyApp.Farms, :list_for_user},
# Required: Function to get display name from context item
display_name: fn farm -> farm.name end,
# Optional settings with defaults shown
id_field: :uuid, # Field or function to get UUID
label: "Farm", # UI label (e.g., "Switch Farm")
icon: "hero-building-office", # Heroicon name
position: :sidebar, # :header or :sidebar
sub_position: :end, # :start, :end, or {:priority, N}
separator: "/", # Header separator (false to disable)
empty_behavior: :hide, # :hide, :show_empty, or {:redirect, "/setup"}
session_key: "dashboard_context_uuid",
# Optional: Dynamic tabs based on context
tab_loader: {MyApp.Farms, :get_tabs_for_context}
```
### Dynamic Tabs Based on Context
The `tab_loader` option allows tabs to change based on the selected context:
```elixir
# In your context module
def get_tabs_for_context(%{type: :personal}) do
[
%{id: :overview, label: "Overview", path: "/dashboard", icon: "hero-home"},
%{id: :settings, label: "Settings", path: "/dashboard/settings", icon: "hero-cog-6-tooth"}
]
end
def get_tabs_for_context(%{type: :team}) do
[
%{id: :overview, label: "Overview", path: "/dashboard", icon: "hero-home"},
%{id: :projects, label: "Projects", path: "/dashboard/projects", icon: "hero-folder"},
%{id: :settings, label: "Settings", path: "/dashboard/settings", icon: "hero-cog-6-tooth"}
]
end
```
### LiveView Integration
Add the ContextProvider to your live_session:
```elixir
live_session :dashboard,
on_mount: [
{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_authenticated_scope},
{PhoenixKitWeb.Dashboard.ContextProvider, :default}
] do
live "/dashboard", DashboardLive.Index
end
```
### Accessing Current Context
```elixir
defmodule MyAppWeb.DashboardLive do
def mount(_params, _session, socket) do
# Context is loaded by on_mount hook
context = socket.assigns.current_context
if context do
items = MyApp.Items.list_for_context(context.uuid)
{:ok, assign(socket, items: items)}
else
{:ok, assign(socket, items: [])}
end
end
end
# Or use helper functions
context = PhoenixKit.Dashboard.current_context(socket)
context_uuid = PhoenixKit.Dashboard.current_context_uuid(socket)
has_multiple = PhoenixKit.Dashboard.has_multiple_contexts?(socket)
```
### Socket Assigns Set by ContextProvider
- `@dashboard_contexts` - List of all contexts available to the user
- `@current_context` - The currently selected context item
- `@show_context_selector` - Boolean, true only if user has 2+ contexts
- `@context_selector_config` - The configuration struct
- `@dashboard_tabs` - (Optional) List of Tab structs when `tab_loader` is configured
### Position Options
The context selector position is controlled by two options: `position` and `sub_position`.
**Position (which area):**
- `:header` - Shows in the page header
- `:sidebar` - Shows in the sidebar navigation
**Sub-position (where within the area):**
For `:header`:
- `:start` - Left side, after the logo (default)
- `:end` - Right side, before the user menu
- `{:priority, N}` - Sorted among other header items by priority
For `:sidebar`:
- `:start` - At the top of the sidebar (default)
- `:end` - Pinned to the very bottom of the sidebar (sticky footer style)
- `{:priority, N}` - Sorted among tabs by priority number
**Examples:**
```elixir
# Header, left side
position: :header, sub_position: :start
# Header, right side
position: :header, sub_position: :end
# Sidebar, at the top
position: :sidebar, sub_position: :start
# Sidebar, pinned to the very bottom
position: :sidebar, sub_position: :end
# Sidebar, between tabs with priority 150
position: :sidebar, sub_position: {:priority, 150}
```
**Header Separator:**
When using `position: :header, sub_position: :start`, a separator character is shown
between the logo/title and the context selector. Configure with:
```elixir
separator: "/" # Default - shows a forward slash
separator: "›" # Use a chevron
separator: "|" # Use a pipe
separator: false # Disable separator entirely
```
> **Note:** The separator may appear slightly off-center visually. This is due to
> internal padding in the selector dropdown creating an optical illusion of uneven
> spacing. If precise alignment is required, customize the layout template directly.
### Behavior
- **Single context**: Selector is hidden, user doesn't know multi-context exists
- **Multiple contexts**: Dropdown appears based on position setting
- **Context switch**: POST to `/phoenix_kit/context/:uuid`, session updated, page redirects back
- **Persistence**: Selection stored in session, survives navigation