feat(nav): bouton action intégré dans la navbar v0.4.8
- ActionButtonContext : contexte React permettant aux pages d'injecter leur bouton action dans la navbar (Shopping=fa-cart-plus, Todos/Notes=+) - BottomNav : 5e slot dédié avec dock circulaire visuel permanent ; bouton rendu 10px au-dessus du centre du slot (effet soulevé) - Layout : ActionButtonProvider + overflow visible sur le conteneur nav - Pages : useEffect enregistre/vide le bouton action — plus de FAB flottant sur le contenu, liste entièrement visible Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "homehub-frontend",
|
"name": "homehub-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.7",
|
"version": "0.4.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { useActionButton } from '../../contexts/ActionButtonContext'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', icon: 'house', label: 'Accueil' },
|
{ to: '/', icon: 'house', label: 'Accueil' },
|
||||||
@@ -8,12 +9,16 @@ const NAV_ITEMS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function BottomNav() {
|
export default function BottomNav() {
|
||||||
|
const { actionButton } = useActionButton()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav style={{
|
<nav style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
background: 'var(--bg-2)',
|
background: 'var(--bg-2)',
|
||||||
borderTop: '1px solid var(--border-2)',
|
borderTop: '1px solid var(--border-2)',
|
||||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||||
|
overflow: 'visible',
|
||||||
|
position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -40,6 +45,30 @@ export default function BottomNav() {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Slot bouton action */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, minHeight: 56,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
{/* Dock — cercle visuel permanent */}
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, borderRadius: '50%',
|
||||||
|
background: 'var(--bg-3)',
|
||||||
|
border: '1.5px solid var(--bg-5)',
|
||||||
|
}} />
|
||||||
|
{/* Bouton action injecté par la page courante */}
|
||||||
|
{actionButton && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%', left: '50%',
|
||||||
|
transform: 'translate(-50%, calc(-50% - 10px))',
|
||||||
|
}}>
|
||||||
|
{actionButton}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
import BottomNav from './BottomNav'
|
import BottomNav from './BottomNav'
|
||||||
import SideNav from './SideNav'
|
import SideNav from './SideNav'
|
||||||
|
import { ActionButtonProvider } from '../../contexts/ActionButtonContext'
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', height: '100dvh', background: 'var(--bg-1)' }}>
|
<ActionButtonProvider>
|
||||||
{/* Sidebar — visible uniquement sur laptop (lg et +) */}
|
<div style={{ display: 'flex', height: '100dvh', background: 'var(--bg-1)' }}>
|
||||||
<div className="hidden lg:flex" style={{ flexShrink: 0 }}>
|
{/* Sidebar — visible uniquement sur laptop (lg et +) */}
|
||||||
<SideNav />
|
<div className="hidden lg:flex" style={{ flexShrink: 0 }}>
|
||||||
</div>
|
<SideNav />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
<main style={{ flex: 1, overflow: 'auto', paddingBottom: 0 }} className="lg:pb-0 pb-14">
|
<main style={{ flex: 1, overflow: 'auto', paddingBottom: 0 }} className="lg:pb-0 pb-14">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Navigation bas — visible uniquement sur mobile */}
|
{/* Navigation bas — visible uniquement sur mobile */}
|
||||||
<div className="lg:hidden" style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 50 }}>
|
<div className="lg:hidden" style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 50, overflow: 'visible' }}>
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ActionButtonProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createContext, useContext, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ActionButtonContextType {
|
||||||
|
actionButton: ReactNode
|
||||||
|
setActionButton: (node: ReactNode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionButtonContext = createContext<ActionButtonContextType>({
|
||||||
|
actionButton: null,
|
||||||
|
setActionButton: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ActionButtonProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [actionButton, setActionButton] = useState<ReactNode>(null)
|
||||||
|
return (
|
||||||
|
<ActionButtonContext.Provider value={{ actionButton, setActionButton }}>
|
||||||
|
{children}
|
||||||
|
</ActionButtonContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActionButton() {
|
||||||
|
return useContext(ActionButtonContext)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Note, NoteFilters } from '../api/notes'
|
|||||||
import { fetchNotes, createNote, updateNote, deleteNote, addAttachment, deleteAttachment } from '../api/notes'
|
import { fetchNotes, createNote, updateNote, deleteNote, addAttachment, deleteAttachment } from '../api/notes'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
import NoteForm from '../components/notes/NoteForm'
|
import NoteForm from '../components/notes/NoteForm'
|
||||||
|
import { useActionButton } from '../contexts/ActionButtonContext'
|
||||||
|
|
||||||
const noSelect: React.CSSProperties = { userSelect: 'none' }
|
const noSelect: React.CSSProperties = { userSelect: 'none' }
|
||||||
|
|
||||||
@@ -163,6 +164,24 @@ export default function NotesPage() {
|
|||||||
const [searchInput, setSearchInput] = useState('')
|
const [searchInput, setSearchInput] = useState('')
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const { setActionButton } = useActionButton()
|
||||||
|
useEffect(() => {
|
||||||
|
setActionButton(
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
aria-label="Nouvelle note"
|
||||||
|
style={{
|
||||||
|
width: 56, height: 56, borderRadius: '50%',
|
||||||
|
background: 'var(--accent)', color: '#1d2021', border: 'none',
|
||||||
|
fontSize: 24, cursor: 'pointer',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>+</button>
|
||||||
|
)
|
||||||
|
return () => setActionButton(null)
|
||||||
|
}, [setActionButton])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -348,20 +367,6 @@ export default function NotesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAB */}
|
|
||||||
<button
|
|
||||||
className="fixed bottom-[10px] right-3 lg:bottom-6 lg:right-6"
|
|
||||||
onClick={() => setShowForm(true)}
|
|
||||||
aria-label="Nouvelle note"
|
|
||||||
style={{
|
|
||||||
width: 56, height: 56, borderRadius: '50%',
|
|
||||||
background: 'var(--accent)', color: '#1d2021',
|
|
||||||
border: 'none', fontSize: 28, cursor: 'pointer',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>+</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// frontend/src/pages/ShoppingPage.tsx
|
// frontend/src/pages/ShoppingPage.tsx
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { matchesSearch } from '../utils/search'
|
import { matchesSearch } from '../utils/search'
|
||||||
import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } from '../api/shopping'
|
import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } from '../api/shopping'
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import ItemRow from '../components/shopping/ItemRow'
|
|||||||
import CatalogueModal from '../components/shopping/CatalogueModal'
|
import CatalogueModal from '../components/shopping/CatalogueModal'
|
||||||
import BoutiquesModal from '../components/shopping/BoutiquesModal'
|
import BoutiquesModal from '../components/shopping/BoutiquesModal'
|
||||||
import { useWakeLock } from '../hooks/useWakeLock'
|
import { useWakeLock } from '../hooks/useWakeLock'
|
||||||
|
import { useActionButton } from '../contexts/ActionButtonContext'
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -76,6 +77,33 @@ export default function ShoppingPage() {
|
|||||||
|
|
||||||
useWakeLock(currentList !== null)
|
useWakeLock(currentList !== null)
|
||||||
|
|
||||||
|
const { setActionButton } = useActionButton()
|
||||||
|
const openAddSheetRef = useRef(openAddSheet)
|
||||||
|
useEffect(() => { openAddSheetRef.current = openAddSheet })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentList || showAddSheet) {
|
||||||
|
setActionButton(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setActionButton(
|
||||||
|
<button
|
||||||
|
onClick={() => openAddSheetRef.current()}
|
||||||
|
aria-label="Ajouter un article"
|
||||||
|
style={{
|
||||||
|
width: 56, height: 56, borderRadius: '50%',
|
||||||
|
background: 'var(--accent)', color: '#1d2021', border: 'none',
|
||||||
|
fontSize: 22, cursor: 'pointer',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-cart-plus" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
return () => setActionButton(null)
|
||||||
|
}, [currentList, showAddSheet, setActionButton])
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -526,22 +554,6 @@ export default function ShoppingPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAB — masqué quand le sheet est ouvert */}
|
|
||||||
{!showAddSheet && (
|
|
||||||
<button
|
|
||||||
onClick={openAddSheet}
|
|
||||||
aria-label="Ajouter un article"
|
|
||||||
style={{
|
|
||||||
position: 'fixed', bottom: 10, right: 12,
|
|
||||||
width: 56, height: 56, borderRadius: '50%',
|
|
||||||
background: 'var(--accent)', color: '#1d2021', border: 'none',
|
|
||||||
fontSize: 22, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-cart-plus" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fetchTodos, createTodo, updateTodo, deleteTodo, postponeTodo } from '..
|
|||||||
import SwipeableRow from '../components/todos/SwipeableRow'
|
import SwipeableRow from '../components/todos/SwipeableRow'
|
||||||
import TodoForm, { DOMAIN_COLORS } from '../components/todos/TodoForm'
|
import TodoForm, { DOMAIN_COLORS } from '../components/todos/TodoForm'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
|
import { useActionButton } from '../contexts/ActionButtonContext'
|
||||||
|
|
||||||
type EditingTodo = Todo | null
|
type EditingTodo = Todo | null
|
||||||
|
|
||||||
@@ -41,6 +42,24 @@ export default function TodosPage() {
|
|||||||
const [editingTodo, setEditingTodo] = useState<EditingTodo>(null)
|
const [editingTodo, setEditingTodo] = useState<EditingTodo>(null)
|
||||||
const [filters, setFilters] = useState<TodoFilters>({ status: 'pending' })
|
const [filters, setFilters] = useState<TodoFilters>({ status: 'pending' })
|
||||||
|
|
||||||
|
const { setActionButton } = useActionButton()
|
||||||
|
useEffect(() => {
|
||||||
|
setActionButton(
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
aria-label="Nouvelle tâche"
|
||||||
|
style={{
|
||||||
|
width: 56, height: 56, borderRadius: '50%',
|
||||||
|
background: 'var(--accent)', color: '#1d2021', border: 'none',
|
||||||
|
fontSize: 24, cursor: 'pointer',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>+</button>
|
||||||
|
)
|
||||||
|
return () => setActionButton(null)
|
||||||
|
}, [setActionButton])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -395,27 +414,6 @@ export default function TodosPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAB création — mobile : au-dessus de la barre de nav ; laptop : coin bas-droit */}
|
|
||||||
<button
|
|
||||||
className="fixed bottom-[10px] right-3 lg:bottom-6 lg:right-6"
|
|
||||||
onClick={() => setShowForm(true)}
|
|
||||||
aria-label="Nouvelle tâche"
|
|
||||||
style={{
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'var(--accent)',
|
|
||||||
color: '#1d2021',
|
|
||||||
border: 'none',
|
|
||||||
fontSize: 28,
|
|
||||||
cursor: 'pointer',
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>+</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user