Now.js Framework Documentation
MenuManager
MenuManager
ภาพรวม
MenuManager คือระบบจัดการ navigation menus ใน Now.js Framework รองรับ responsive menus, submenus และ touch devices
ใช้เมื่อ:
- ต้องการ navigation menu
- ต้องการ responsive hamburger menu
- ต้องการ dropdown submenus
- ต้องการ active state tracking
ทำไมต้องใช้:
- ✅ Responsive (mobile/desktop)
- ✅ Dropdown submenus
- ✅ Touch gesture support
- ✅ Keyboard navigation
- ✅ ARIA accessibility
- ✅ Auto-active state
การใช้งานพื้นฐาน
HTML Structure
<nav data-component="menu">
<button class="menu-toggle">☰</button>
<ul class="menu">
<li><a href="/">Home</a></li>
<li>
<a href="/products">Products</a>
<ul>
<li><a href="/products/new">New</a></li>
<li><a href="/products/sale">Sale</a></li>
</ul>
</li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>Initialization
await MenuManager.init({
breakpoint: 768,
animationDuration: 200
});การตั้งค่า
MenuManager.init({
// Mobile breakpoint (px)
breakpoint: 768,
// Animation duration (ms)
animationDuration: 100,
// Touch swipe threshold (px)
touchThreshold: 100,
// Accessibility
accessibility: {
enabled: true,
announceChanges: true,
smoothFocus: true
},
// Hover delays
hover: {
openDelay: 100,
closeDelay: 300
}
});Data Attributes
| Attribute | Description |
|---|---|
data-component="menu" |
Initialize menu |
data-source |
API source for menu items |
เมนูที่ active
ทุกครั้งที่เปลี่ยนหน้าและทุกครั้งที่เมนูถูกวาดใหม่ MenuManager จะหาลิงก์ที่ตรงกับหน้าปัจจุบัน แล้วใส่ class active และ aria-current="page" ให้ path ที่ใช้เทียบมาจากที่แรกที่มีค่าตามลำดับนี้
- อาร์กิวเมนต์ของ
updateActiveMenu(path) menuPathของ route ปัจจุบัน ค่านี้มาจากmenuPathใน config ของ route หรือจากmenu_pathที่ API ส่งมา (ดูหัวข้อถัดไป)pathname + searchของแถบที่อยู่
ลิงก์ที่มี query string จะนับว่าตรงเมื่อ query ตรงกันครบทุกตัว (ลำดับไม่สำคัญ) ส่วนลิงก์ที่ไม่มี query เทียบแค่ path ถ้าไม่มีลิงก์ไหนตรงเลย จะเลือกลิงก์ที่ path ยาวที่สุดซึ่งเป็นส่วนต้นของ path ปัจจุบัน
ให้ API เลือกเมนู (menu_path)
หน้ารายละเอียดมักเป็นของลิงก์กรองตัวใดตัวหนึ่งในเมนู เช่นเมนูมี /orders?status=QUO กับ /orders?status=INV แต่ที่อยู่ของหน้ารายละเอียดมีแค่ /order?id=248 ซึ่งไม่บอกว่าเป็นเอกสารชนิดไหน
route ทำได้แค่ตั้ง menuPath: '/orders' ที่ไม่ตรงกับลิงก์ไหนเลย สุดท้ายจึงได้ลิงก์แรกเสมอ คนที่รู้ว่าใบนี้อยู่ใต้เมนูไหนมีแค่ server
ถ้าให้ข้อมูลของหน้าส่ง menu_path มาด้วย ค่านั้นจะถูกใช้แทน menuPath ของ route ปัจจุบัน
// api/inventory/order/get
$order['menu_path'] = '/inventory-orders?status='.$order['document_type'];
return $this->successResponse([
'data' => $order,
'options' => $options
], 'OK');- ใช้ได้กับข้อมูลที่โหลดผ่าน
data-load-apiของ FormManager และdata-component="api"ของ ApiComponent - วาง
menu_pathไว้ระดับเดียวกับฟิลด์ที่ผูกกับเทมเพลต คือในdataของsuccessResponse(['data' => ...])หรือในข้อมูลของsuccessResponse($row)ได้ทั้งสองแบบ - ไม่ส่งมา หรือส่งเป็นค่าว่าง = เลือกเมนูแบบเดิมตามลำดับข้างบน
- ค่าจะอยู่ถึงแค่ตอนออกจากหน้า เพราะ router สร้าง route ปัจจุบันขึ้นใหม่ทุกครั้งที่เปลี่ยนหน้า หน้าถัดไปจึงกลับไปใช้
menuPathใน config ตามเดิม - เมนูที่วาดเสร็จทีหลังข้อมูล (เช่น sidebar ที่โหลดข้อมูลของตัวเอง) ก็ยังเลือกลิงก์ตาม
menu_path - response ที่มาถึงหลังผู้ใช้ออกจากหน้านั้นไปแล้วจะไม่มีผลกับเมนูของหน้าใหม่
- ค่าที่ส่งต้องเป็นลิงก์ที่มีอยู่ในเมนู ถ้าไม่มีลิงก์ที่ตรง จะเลือกตามกติกา path ยาวที่สุดตามเดิม
- ถ้า
menu_pathมาจาก API ของ modal ก็จะเปลี่ยนเมนูของหน้าด้วย จึงควรส่งมาเฉพาะจาก API ที่เป็นข้อมูลของหน้า
API อ้างอิง
MenuManager.createMenu(element)
สร้าง menu instance
| Parameter | Type | Description |
|---|---|---|
element |
HTMLElement | Menu container |
Returns: string - Menu ID
MenuManager.openMenu(menu)
เปิด mobile menu
MenuManager.closeMenu(menu)
ปิด mobile menu
MenuManager.toggleMenu(menu)
Toggle mobile menu
MenuManager.openSubmenu(menu, button, options?)
เปิด submenu
MenuManager.closeSubmenu(menu, button)
ปิด submenu
MenuManager.closeAllSubmenus(menu, options?)
ปิด submenus ทั้งหมด · {keepActive: true} เว้น submenu ที่มีลิงก์ active อยู่ข้างใน
ตอนเปลี่ยนหน้า (route:changed) MenuManager เลือกลิงก์ active ใหม่ แล้วปิด submenu อื่นด้วย keepActive — กิ่งที่มีหน้าปัจจุบันจึงยังกางอยู่ แม้เมนูจะไม่ถูกวาดใหม่ (sidebar ใน layout ของ RouterManager)
MenuManager.updateActiveMenu(path)
เลือกลิงก์ที่ active ใหม่ตามกติกาใน เมนูที่ active และส่ง event menu:activeChanged เมื่อลิงก์ที่เลือกเปลี่ยน
| Parameter | Type | Description |
|---|---|---|
path |
string | (ไม่บังคับ) path ที่ใช้เทียบ ถ้าไม่ส่งจะใช้ menuPath ของ route ปัจจุบันหรือแถบที่อยู่ |
Returns: Element[] - ลิงก์ที่ active
MenuManager.updateActiveMenu('/products/new');เมื่อลิงก์ที่เลือกเปลี่ยน submenu ที่ครอบลิงก์จะกาง (รายการในนั้นกด Tab ถึงได้) และถ้าลิงก์อยู่ใน .sidemenu ที่เลื่อนได้ (กล่องบรรพบุรุษที่มี overflow-y: auto|scroll และเนื้อหาล้น) จะเลื่อนกล่องนั้นให้เห็นลิงก์ในเฟรมถัดไป — ไม่เลื่อนหน้าเว็บ
path ที่ส่งเป็นอาร์กิวเมนต์มีผลกับการเรียกครั้งนี้ครั้งเดียว เมนูที่วาดใหม่ทีหลังจะกลับไปเลือกตาม route ถ้าต้องการให้ค่าอยู่ตลอดหน้า ให้ใช้ applyMenuPath()
MenuManager.applyMenuPath(payload)
ถ้าข้อมูลมี menu_path (ระดับบนสุด หรือใน data) จะตั้งค่านี้เป็น menuPath ของ route ปัจจุบันแล้วเลือกเมนูใหม่ ถ้าไม่มี จะไม่ทำอะไร
FormManager กับ ApiComponent เรียกให้เองหลังโหลดข้อมูล ปกติจึงไม่ต้องเรียกเอง — ดู ให้ API เลือกเมนู
| Parameter | Type | Description |
|---|---|---|
payload |
Object | ข้อมูลที่หน้าโหลดมา |
Returns: boolean - true เมื่อข้อมูลระบุ menu_path
MenuManager.getMenu(menuId)
รับ menu instance
MenuManager.hasMenu(menuId)
ตรวจสอบ menu exists
Keyboard Navigation
| Key | Action |
|---|---|
↓ / → |
Next item |
↑ / ← |
Previous item |
Enter / Space |
Activate/toggle |
Escape |
Close submenu |
Tab |
Navigate focus |
การจัดรูปแบบ CSS
/* Menu container */
[data-component="menu"] {
position: relative;
}
/* Menu list */
.menu {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.menu > li {
position: relative;
}
.menu a {
display: block;
padding: 12px 16px;
color: inherit;
text-decoration: none;
}
.menu a:hover {
background: rgba(0,0,0,0.05);
}
/* Active state */
.menu a.active {
color: #3b82f6;
font-weight: 500;
}
/* Submenu */
.menu ul {
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: white;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0;
visibility: hidden;
transition: all 0.2s;
}
.menu li:hover > ul {
opacity: 1;
visibility: visible;
}
/* Mobile toggle */
.menu-toggle {
display: none;
}
/* Mobile responsive */
@media (max-width: 768px) {
.menu-toggle {
display: block;
}
.menu {
position: fixed;
top: 0;
left: -280px;
width: 280px;
height: 100vh;
flex-direction: column;
background: white;
transition: left 0.3s;
}
.menu.open {
left: 0;
}
.menu ul {
position: static;
box-shadow: none;
}
}ตัวอย่างการใช้งานจริง
Main Navigation
<header>
<nav data-component="menu">
<a href="/" class="logo">Logo</a>
<button class="menu-toggle" aria-label="Menu">
<span></span>
<span></span>
<span></span>
</button>
<ul class="menu">
<li><a href="/">Home</a></li>
<li>
<button aria-expanded="false">Products ▼</button>
<ul>
<li><a href="/products/software">Software</a></li>
<li><a href="/products/hardware">Hardware</a></li>
<li><a href="/products/services">Services</a></li>
</ul>
</li>
<li><a href="/pricing">Pricing</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
</header>Data-Driven Menu
<nav data-component="menu" data-source="api.menus">
<!-- Menu will be rendered from API data -->
</nav>
<script>
// API response structure
const menus = [
{ label: 'Home', href: '/' },
{
label: 'Products',
children: [
{ label: 'New', href: '/products/new' },
{ label: 'Sale', href: '/products/sale' }
]
}
];
</script>Sidebar Menu
<aside data-component="menu" class="sidebar-menu">
<ul class="menu vertical">
<li><a href="/dashboard">Dashboard</a></li>
<li>
<button>Settings</button>
<ul>
<li><a href="/settings/profile">Profile</a></li>
<li><a href="/settings/security">Security</a></li>
</ul>
</li>
<li><a href="/logout">Logout</a></li>
</ul>
</aside>เหตุการณ์
| Event | เมื่อเกิด | Detail |
|---|---|---|
menu:opened |
เปิดเมนู | {menu} |
menu:closed |
ปิดเมนู | {menu} |
submenu:opened |
เปิดเมนูย่อย | {menu, button, submenu, previousFocus} |
submenu:closed |
ปิดเมนูย่อย | {menu, button, submenu, currentFocus} |
menu:activeChanged |
รายการที่ active เปลี่ยนตาม route | {path, hash, activeLinks, previousActive} |
menu:rendered |
เรนเดอร์เมนูจากข้อมูลเสร็จ | {container, itemCount} |
menumanager:destroyed |
destroy() เสร็จ | ไม่มี |
EventManager.on('menu:activeChanged', ({path, activeLinks}) => {
console.log('Active menu changed:', path, activeLinks.length);
});ข้อควรระวัง
⚠️ 1. Submenu ต้องอยู่ใน li
<!-- ❌ Submenu อยู่ผิดที่ -->
<ul class="menu">
<a href="#">Products</a>
<ul>...</ul>
</ul>
<!-- ✅ Submenu ใน li -->
<ul class="menu">
<li>
<a href="#">Products</a>
<ul>...</ul>
</li>
</ul>⚠️ 2. ARIA สำหรับ Accessibility
<!-- ❌ ไม่มี ARIA -->
<button>Menu</button>
<!-- ✅ มี ARIA -->
<button aria-expanded="false" aria-controls="main-menu">
Menu
</button>⚠️ 3. MenuManager รองรับแค่ 2 ระดับการซ้อน
Loop ของ buildMenu() มีเงื่อนไข if (level < 2) — จะ render children ให้เฉพาะระดับ 0 (top) และระดับ 1 (submenu แรก)
ระดับที่สามจะถูกเพิกเฉยโดยไม่มี error ใดๆ
// ❌ 3 ระดับการซ้อน — ระดับที่สามจะไม่แสดงผล
$menus['settings'] = [
'title' => 'Settings',
'children' => [
[
'title' => 'Widgets', // ระดับ 1
'children' => [ // ระดับ 2 — ถูกเพิกเฉย
['title' => 'Facebook', 'url' => '/widgets/facebook'],
]
]
]
];
// ✅ ย้าย Widgets ขึ้นเป็น top-level group แยกต่างหาก
$menus['settings'] = [
'title' => 'Settings',
'children' => [
['title' => 'General', 'url' => '/general-settings'],
...
]
];
$menus['widgets'] = [ // top-level group
'title' => 'Widgets',
'icon' => 'icon-widgets',
'children' => [
['title' => 'Facebook', 'url' => '/widgets/facebook', 'icon' => 'icon-facebook'],
['title' => 'Text links', 'url' => '/widgets/textlinks', 'icon' => 'icon-ads'],
]
];เอกสารที่เกี่ยวข้อง
- RouterManager - SPA routing
- AnimationManager - Animations