Now.js Framework Documentation

Now.js Framework Documentation

MenuManager

TH 09 Nov 2026 03:28

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 ที่ใช้เทียบมาจากที่แรกที่มีค่าตามลำดับนี้

  1. อาร์กิวเมนต์ของ updateActiveMenu(path)
  2. menuPath ของ route ปัจจุบัน ค่านี้มาจาก menuPath ใน config ของ route หรือจาก menu_path ที่ API ส่งมา (ดูหัวข้อถัดไป)
  3. 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 อ้างอิง

สร้าง menu instance

Parameter Type Description
element HTMLElement Menu container

Returns: string - Menu ID

เปิด mobile menu

ปิด mobile menu

Toggle mobile menu

เปิด submenu

ปิด submenu

ปิด submenus ทั้งหมด · {keepActive: true} เว้น submenu ที่มีลิงก์ active อยู่ข้างใน

ตอนเปลี่ยนหน้า (route:changed) MenuManager เลือกลิงก์ active ใหม่ แล้วปิด submenu อื่นด้วย keepActive — กิ่งที่มีหน้าปัจจุบันจึงยังกางอยู่ แม้เมนูจะไม่ถูกวาดใหม่ (sidebar ใน layout ของ RouterManager)

เลือกลิงก์ที่ 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()

ถ้าข้อมูลมี menu_path (ระดับบนสุด หรือใน data) จะตั้งค่านี้เป็น menuPath ของ route ปัจจุบันแล้วเลือกเมนูใหม่ ถ้าไม่มี จะไม่ทำอะไร
FormManager กับ ApiComponent เรียกให้เองหลังโหลดข้อมูล ปกติจึงไม่ต้องเรียกเอง — ดู ให้ API เลือกเมนู

Parameter Type Description
payload Object ข้อมูลที่หน้าโหลดมา

Returns: boolean - true เมื่อข้อมูลระบุ menu_path

รับ menu instance

ตรวจสอบ 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;
  }
}

ตัวอย่างการใช้งานจริง

<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>
<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'],
  ]
];

เอกสารที่เกี่ยวข้อง