diff --git a/docs/docs/ui/agent-guide.md b/docs/docs/ui/agent-guide.md index 96d27ae1..889bef80 100644 --- a/docs/docs/ui/agent-guide.md +++ b/docs/docs/ui/agent-guide.md @@ -119,6 +119,7 @@ They are **not** fine as the public way apps collect domain input when a form co | Date / time | `DatePicker` / `TimePicker` / `DateTimePicker` (+ `.form`) | | Files | `FileDropZone` (+ `.form`) | | Color seed | `ColorPicker` (+ `.form`) | +| Action / nav popup menu | `DropdownMenu` (or `TopBar.item.dropdown`) — not a hand-rolled overlay | | Page shell | `HolyGrail` / `CenteredCard` (`layout`) | --- diff --git a/docs/docs/ui/builders.md b/docs/docs/ui/builders.md index be1221be..4b9229f5 100644 --- a/docs/docs/ui/builders.md +++ b/docs/docs/ui/builders.md @@ -149,6 +149,27 @@ HolyGrail.empty **Height fighting:** set height on **either** HolyGrail’s top row (`.topHeight`) **or** `TopBar.barHeight`, not both. Under HolyGrail, leave TopBar height unset so the grid row owns size. +### Responsive TopBar (overflow menu) + +`TopBar.nav(…)` declares nav items **once** as typed `DropdownMenu.Item`s. At `>= md` they render inline next to the `left` slot; below `md` they auto-collapse into a single **"More"** dropdown (the reused OXY-152 `DropdownMenu` — same scrim / keyboard / a11y). The swap is **pure CSS** (`TopBar.responsiveSheet`, in `coreOxygenStyleSheets`) — no JS/`matchMedia`, so it is SSR/hydration safe (no FOUC). + +```scala +TopBar.empty.brand + .left(TopBar.item.index("MyApp").onClickPush(HomePage)) + .nav( + TopBar.menuItem("Home").withIcon(Icon.home).onClickPush(HomePage), + TopBar.menuItem("Products").withIcon(Icon.grid).onClickPush(ProductsPage), + TopBar.menuSeparator, + TopBar.menuItem("About").onClickPush(AboutPage), + ) + .right(TopBar.item.dropdownWithIcon("user", Icon.user, "Jane")(userMenu*)) +``` + +- `TopBar.menuItem(…)` / `TopBar.menuSeparator` build the shared items (`onClickPush` / `onSelect` / `withIcon` / `disabled`). Separators are dropped in the inline layout, kept in the collapsed menu. +- `.moreLabel("Menu")` renames the collapsed trigger; `.moreId("…")` sets a unique open-state id (default `"topbar-overflow"` — override if you mount more than one responsive TopBar on a page). +- Breakpoint is `md` (`style.Breakpoints.md`, 768px), matching `HolyGrail.responsiveSheet`. +- Showcase: **Responsive TopBar** page. + ### CenteredCard Auth / marketing body preset in `oxygen.ui.web.layout.CenteredCard` — not a form system. @@ -222,7 +243,7 @@ These are intentional honesty notes for agents and humans (from the UI overhaul | `Drawer` | Works; Deferred conversion still TODO | | `Button.form` | Internally uses `Button.Const` today; full Env/Action/State params are a known follow-up | | `LockAware` | Revisit after remaining component cleanup | -| Mobile shell | Viewport meta helps a lot; narrow layouts still rough | +| Mobile shell | Viewport meta + `TopBar.nav(…)` overflow menu land the nav; hamburger→`Drawer` side nav still TODO (rest of OXY-151) | | `PageHtmlResponse` | Needs OG / social meta support (TODO) | | `service.Broadcast` / `MatchMedia` / some IDB edges | APIs may still change — prefer Theme/ColorMode patterns | diff --git a/example/apps/ui/src/main/scala/oxygen/example/ui/UIMain.scala b/example/apps/ui/src/main/scala/oxygen/example/ui/UIMain.scala index b88d71d2..2737b1bc 100644 --- a/example/apps/ui/src/main/scala/oxygen/example/ui/UIMain.scala +++ b/example/apps/ui/src/main/scala/oxygen/example/ui/UIMain.scala @@ -65,6 +65,8 @@ object UIMain extends PageApp[UIMain.Env] { P.showcase.pages.FormAllPage, P.showcase.pages.ModalPage, P.showcase.pages.DrawerPage, + P.showcase.pages.DropdownMenuPage, + P.showcase.pages.ResponsiveTopBarPage, P.showcase.pages.TooltipPage, P.showcase.pages.TablePage, P.showcase.pages.FeedPage, diff --git a/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/ShowcaseLayout.scala b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/ShowcaseLayout.scala index c96e40ce..249b038d 100644 --- a/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/ShowcaseLayout.scala +++ b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/ShowcaseLayout.scala @@ -56,6 +56,8 @@ object ShowcaseLayout { navItem("All form fields", FormAllPage, currentPath), navItem("Modal", ModalPage, currentPath), navItem("Drawer", DrawerPage, currentPath), + navItem("Dropdown menu", DropdownMenuPage, currentPath), + navItem("Responsive TopBar", ResponsiveTopBarPage, currentPath), navItem("Tooltips", TooltipPage, currentPath), navItem("Table", TablePage, currentPath), navItem("Feed", FeedPage, currentPath), diff --git a/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/DropdownMenuPage.scala b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/DropdownMenuPage.scala new file mode 100644 index 00000000..76b975d0 --- /dev/null +++ b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/DropdownMenuPage.scala @@ -0,0 +1,73 @@ +package oxygen.example.ui.page.showcase.pages + +import oxygen.example.ui.page.showcase.ShowcaseLayout +import oxygen.ui.web.* +import oxygen.ui.web.component.* +import oxygen.ui.web.create.{*, given} + +object DropdownMenuPage extends ShowcaseLayout.SimplePage { + override val path: Seq[String] = Seq("showcase", "overlays", "dropdown-menu") + override def pageTitle: String = "Dropdown menu" + + private def toast(msg: String) = PageMessages.add(PageMessage.info(msg)) + + /** A TopBar with left nav dropdown + right user-menu dropdown (disabled item + separator). */ + private def demoBar: TopBar.Const = + TopBar.empty + .surface + .barHeight(48.px) + .left( + TopBar.item("Home").onClickPush(ShowcaseHubPage.nav()), + TopBar.item.dropdown("showcase-products", "Products")( + TopBar.menuItem("Overview").withIcon(Icon.grid).onSelect(toast("Products → Overview")), + TopBar.menuItem("Pricing").withIcon(Icon.tag).onSelect(toast("Products → Pricing")), + TopBar.menuSeparator, + TopBar.menuItem("Enterprise (soon)").disabled, + ), + ) + .right( + TopBar.item.dropdownWithIcon("showcase-user", Icon.user, "Jane")( + TopBar.menuItem("Profile").withIcon(Icon.user).onSelect(toast("Profile")), + TopBar.menuItem("Settings").withIcon(Icon.settings).onSelect(toast("Settings")), + TopBar.menuSeparator, + TopBar.menuItem("Sign out").withIcon(Icon.logOut).onSelect(toast("Signed out")), + ), + ) + + /** A standalone DropdownMenu (reusable outside TopBar — e.g. SideBar / overflow menus). */ + private def standalone: Widget = + DropdownMenu("showcase-standalone", Icon.moreHorizontal.md, span("Actions")) + .items( + DropdownMenu.item("Rename").withIcon(Icon.edit).onSelect(toast("Rename")), + DropdownMenu.item("Duplicate").withIcon(Icon.copy).onSelect(toast("Duplicate")), + DropdownMenu.separator, + DropdownMenu.item("Delete").withIcon(Icon.trash).onSelect(toast("Delete")), + DropdownMenu.item("Archived (soon)").disabled, + ) + .trigger( + padding := css(S.spacing._2, S.spacing._3), + border := s"1px solid ${S.color.fg.subtle}", + borderRadius := S.borderRadius._3, + backgroundColor := S.color.bg.layerOne, + color := S.color.fg.default, + ) + + override def body: Widget = + fragment( + ShowcaseLayout.note( + "Click a trigger to open. Keyboard: Enter/Space/↓ open; ↑/↓ move (wrap); Home/End; " + + "Enter/Space select; Esc closes and restores focus. Outside-click closes.", + ), + h3("In a TopBar"), + div( + border := s"1px solid ${S.color.fg.subtle}", + borderRadius := S.borderRadius._3, + overflow.visible, + marginBottom := S.spacing._6, + demoBar, + ), + h3("Standalone (reusable panel)"), + p(color := S.color.fg.moderate, fontSize := S.fontSize._2, "Same component TopBar uses — drop it anywhere."), + standalone, + ) +} diff --git a/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/ResponsiveTopBarPage.scala b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/ResponsiveTopBarPage.scala new file mode 100644 index 00000000..c1df083b --- /dev/null +++ b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/ResponsiveTopBarPage.scala @@ -0,0 +1,73 @@ +package oxygen.example.ui.page.showcase.pages + +import oxygen.example.ui.page.showcase.ShowcaseLayout +import oxygen.ui.web.* +import oxygen.ui.web.component.* +import oxygen.ui.web.create.{*, given} + +object ResponsiveTopBarPage extends ShowcaseLayout.SimplePage { + override val path: Seq[String] = Seq("showcase", "responsive-topbar") + override def pageTitle: String = "Responsive TopBar (overflow menu)" + + private def toast(msg: String) = PageMessages.add(PageMessage.info(msg)) + + /** + * A TopBar whose nav items (OXY-151) render inline at `>= md`, and auto-collapse into a single + * "More" [[DropdownMenu]] (OXY-152) below `md`. The swap is pure CSS — resize the frame to see it. + */ + private def demoBar(idSuffix: String): TopBar.Const = + TopBar.empty + .brand + .barHeight(52.px) + .moreId(s"topbar-overflow-$idSuffix") + .left( + TopBar.item.index("MyApp").onClickPush(ShowcaseHubPage.nav()), + ) + .nav( + TopBar.menuItem("Home").withIcon(Icon.home).onSelect(toast("Home")), + TopBar.menuItem("Products").withIcon(Icon.grid).onSelect(toast("Products")), + TopBar.menuItem("Pricing").withIcon(Icon.tag).onSelect(toast("Pricing")), + TopBar.menuItem("Docs").withIcon(Icon.book).onSelect(toast("Docs")), + TopBar.menuSeparator, + TopBar.menuItem("About").onSelect(toast("About")), + TopBar.menuItem("Enterprise (soon)").disabled, + ) + .right( + TopBar.item.dropdownWithIcon(s"responsive-user-$idSuffix", Icon.user, "Jane")( + TopBar.menuItem("Profile").withIcon(Icon.user).onSelect(toast("Profile")), + TopBar.menuSeparator, + TopBar.menuItem("Sign out").withIcon(Icon.logOut).onSelect(toast("Signed out")), + ), + ) + + /** Same bar in a deliberately narrow frame so the overflow "More" menu is always shown. */ + private def narrowFrame: Widget = + div( + width := 360.px, + maxWidth := 100.pct, + border := s"1px solid ${S.color.fg.subtle}", + borderRadius := S.borderRadius._3, + overflow.visible, + demoBar("narrow"), + ) + + override def body: Widget = + fragment( + ShowcaseLayout.note( + "Nav items are declared once via TopBar.nav(...). At >= md they render inline; below md they " + + "auto-collapse into a single \"More\" dropdown (the reused OXY-152 DropdownMenu). No JS — the " + + "swap is pure CSS @media, so it is SSR/hydration safe.", + ), + h3("Live bar (resize the window / frame to cross the md breakpoint)"), + div( + border := s"1px solid ${S.color.fg.subtle}", + borderRadius := S.borderRadius._3, + overflow.visible, + marginBottom := S.spacing._6, + demoBar("live"), + ), + h3("Forced-narrow frame (always shows the collapsed \"More\" menu)"), + p(color := S.color.fg.moderate, fontSize := S.fontSize._2, "The 360px wrapper is below md, so only the overflow menu is visible."), + narrowFrame, + ) +} diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/component/DropdownMenu.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/component/DropdownMenu.scala new file mode 100644 index 00000000..97ab542a --- /dev/null +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/component/DropdownMenu.scala @@ -0,0 +1,298 @@ +package oxygen.ui.web.component + +import org.scalajs.dom +import org.scalajs.dom.HTMLElement +import oxygen.predef.core.* +import oxygen.ui.web.* +import oxygen.ui.web.create.{*, given} +import oxygen.ui.web.service.Window +import zio.* +import zio.http.URL + +/** + * Action / nav popup menu (OXY-152; reserved by the `component` package TODO). + * + * A click-to-open menu of typed [[DropdownMenu.Item]]s. Owns its own open/closed state internally + * (a per-instance [[PageLocalState]] keyed by a caller-supplied stable `id`), so it drops in anywhere + * as a plain stateless widget — no need to thread a `Boolean` through page state. Reused by + * [[TopBar]] dropdown items and available for SideBar / overflow menus. + * + * Behaviour: + * - opens on click / `Enter` / `Space` / `ArrowDown` on the trigger, + * - closes on outside-click (transparent scrim), `Escape` (restores focus to trigger), `Tab`, or select, + * - `ArrowUp`/`ArrowDown` roving (wraps), `Home`/`End`, `Enter`/`Space` activate the focused item. + * + * A11y: trigger `role=button` + `aria-haspopup=menu` + `aria-expanded`; panel `role=menu`; items + * `role=menuitem` (+ `aria-disabled`). Styles live in [[oxygen.ui.web.create.OxygenStyleSheet.DropdownMenu]] + * (`O.DropdownMenu`), included via [[oxygen.ui.web.defaults.coreOxygenStyleSheets]]. + * + * {{{ + * DropdownMenu("nav-products", "Products") + * .items( + * DropdownMenu.item("Overview").onClickPush(OverviewPage.nav()), + * DropdownMenu.item("Pricing").withIcon(Icon.tag).onClickPush(PricingPage.nav()), + * DropdownMenu.separator, + * DropdownMenu.item("Coming soon").disabled, + * ) + * }}} + * + * NOTE (v1): one flat level (no nested submenus); positioned `absolute` under the trigger (an ancestor + * with `overflow:hidden` could clip — a portal/`fixed` variant is future work). Trigger opens on click, + * not hover. + */ +final case class DropdownMenu[-Env, +Action]( + private val _id: String, + private val _trigger: Growable[Widget], + private val _items: Seq[DropdownMenu.Item[Env, Action]], + private val _align: DropdownMenu.Align, + private val _showCaret: Boolean, + private val _ariaLabel: Option[String], +) extends PWidget.Deferred[Env, Action, Any, Nothing] { + + def items[Env2 <: Env, Action2 >: Action]( + addItems: DropdownMenu.Item[Env2, Action2]*, + ): DropdownMenu[Env2, Action2] = + copy(_items = _items ++ addItems) + + def trigger(mods: Widget*): DropdownMenu[Env, Action] = copy(_trigger = _trigger ++ Growable.many(mods)) + + def align(a: DropdownMenu.Align): DropdownMenu[Env, Action] = copy(_align = a) + def alignStart: DropdownMenu[Env, Action] = align(DropdownMenu.Align.Start) + def alignEnd: DropdownMenu[Env, Action] = align(DropdownMenu.Align.End) + + def caret: DropdownMenu[Env, Action] = copy(_showCaret = true) + def noCaret: DropdownMenu[Env, Action] = copy(_showCaret = false) + + def ariaLabel(label: String): DropdownMenu[Env, Action] = copy(_ariaLabel = label.some) + + override protected def build: PWidget[Env, Action, Any, Nothing] = + DropdownMenu.openStateFor(_id).attach { st => + val isOpen: Boolean = st.renderTimeValue + val ddId: String = _id + + val triggerNode: WidgetEAS[Env, Action, Boolean] = + div( + O.DropdownMenu.Trigger, + id := DropdownMenu.triggerElemId(ddId), + Widget.raw.htmlAttr("role", "button"), + Widget.raw.htmlAttr("aria-haspopup", "menu"), + Widget.raw.htmlAttr("aria-expanded", isOpen.toString), + Widget.raw.htmlAttr("tabindex", "0"), + _ariaLabel.map(Widget.raw.htmlAttr("aria-label", _)).getOrElse(Widget.empty), + Widget.fragment(_trigger.to[Seq]), + Widget.when(_showCaret)(span(O.DropdownMenu.Caret, Icon.chevronDown.sm)), + onClick.s[Boolean].handle { s => + if isOpen then s.set(false) + else DropdownMenu.openAndFocus(s, ddId) + }, + onKeyDown.es[Boolean].handle { (s, e) => + e.key match { + case "Enter" | " " | "ArrowDown" => + e.preventDefault() + DropdownMenu.openAndFocus(s, ddId) + case "Escape" => + s.set(false) + case _ => + ZIO.unit + } + }, + ) + + div( + O.DropdownMenu.optMods(_.Open -> isOpen), + triggerNode, + Widget.when(isOpen)( + fragment( + div( + O.DropdownMenu.Scrim, + onClick.s[Boolean].handle(_.set(false)), + ), + div( + O.DropdownMenu.Panel.optMods( + _.AlignStart -> (_align == DropdownMenu.Align.Start), + _.AlignEnd -> (_align == DropdownMenu.Align.End), + ), + Widget.raw.htmlAttr(DropdownMenu.panelDataAttr, ddId), + Widget.raw.htmlAttr("role", "menu"), + onKeyDown.es[Boolean].handle { (s, e) => DropdownMenu.panelKeyDown(s, e, ddId) }, + Widget.foreach(_items.toList) { item => DropdownMenu.renderItem(item) }, + ), + ), + ), + ) + } + +} +object DropdownMenu { + + enum Align { case Start, End } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Entry points + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * @param id stable identifier for this menu's open/closed state — must be unique + stable per call site + * (used to key the internal [[PageLocalState]] across re-renders). + */ + def apply(id: String, triggerContent: Widget*): DropdownMenu[Any, Nothing] = + new DropdownMenu(id, Growable.many(triggerContent), Nil, Align.Start, _showCaret = true, None) + + /** Label + optional caret trigger. */ + def apply(id: String): DropdownMenu[Any, Nothing] = apply(id, Widget.empty) + + val item: Item.Const = Item.empty + def item(label: String): Item.Const = Item.empty.label(label) + + val separator: Item.Const = Item.empty.copy(_isSeparator = true) + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Item + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * One menu entry: a label (+ optional leading icon), or a [[separator]]. Activation is one of + * `onClickPush` (navigate), `onSelectAction` (raise an action), or `onSelect` (arbitrary effect). + */ + final case class Item[-Env, +Action] private[DropdownMenu] ( + private[DropdownMenu] val _label: String, + private[DropdownMenu] val _icon: Option[Icon], + private[DropdownMenu] val _isDisabled: Boolean, + private[DropdownMenu] val _isSeparator: Boolean, + private[DropdownMenu] val _onSelect: RaiseHandler[Any, Action] => ZIO[Env & Scope, UIError, Unit], + ) { + + def label(text: String): Item[Env, Action] = copy(_label = text) + def withIcon(icon: Icon): Item[Env, Action] = copy(_icon = icon.some) + def disabled: Item[Env, Action] = copy(_isDisabled = true) + def disabled(value: Boolean): Item[Env, Action] = copy(_isDisabled = value) + + def onClickPush(nav: RoutablePage.Navigate): Item[Env, Action] = copy(_onSelect = _ => nav.push) + def onClickPush(page: RoutablePage[?])(params: page.PageParams): Item[Env, Action] = onClickPush(page.navigate(params)) + def onClickPush(page: RoutablePage[?])(using ev: Unit <:< page.PageParams): Item[Env, Action] = onClickPush(page)(ev(())) + def onClickPush(url: => URL): Item[Env, Action] = copy(_onSelect = _ => Window.location.assign(url.encode)) + + def onClickReplace(nav: RoutablePage.Navigate): Item[Env, Action] = copy(_onSelect = _ => nav.replace) + def onClickReplace(page: RoutablePage[?])(params: page.PageParams): Item[Env, Action] = onClickReplace(page.navigate(params)) + def onClickReplace(page: RoutablePage[?])(using ev: Unit <:< page.PageParams): Item[Env, Action] = onClickReplace(page)(ev(())) + + /** Raise a page-level action when selected. */ + def onSelectAction[Action2 >: Action](action: Action2): Item[Env, Action2] = + new Item[Env, Action2](_label, _icon, _isDisabled, _isSeparator, _.raiseAction(action)) + + /** Run an arbitrary effect when selected. */ + def onSelect[Env2 <: Env](effect: => ZIO[Env2 & Scope, UIError, Unit]): Item[Env2, Action] = + new Item[Env2, Action](_label, _icon, _isDisabled, _isSeparator, _ => effect) + + // Accessors so sibling components (e.g. TopBar responsive overflow) can render the same + // typed item inline without duplicating the item model. + private[component] def barLabel: String = _label + private[component] def barIcon: Option[Icon] = _icon + private[component] def barDisabled: Boolean = _isDisabled + private[component] def barSeparator: Boolean = _isSeparator + private[component] def barSelect(rh: RaiseHandler[Any, Action]): ZIO[Env & Scope, UIError, Unit] = _onSelect(rh) + + } + object Item { + type Const = Item[Any, Nothing] + val empty: Item.Const = Item("", None, _isDisabled = false, _isSeparator = false, _ => ZIO.unit) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Internal state registry + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + // scala.js is single-threaded; a plain mutable map memoizes one PageLocalState per stable id + // (state itself is scoped per page instance internally). Keeps `id`-keyed state stable across re-renders. + private val openStates: scala.collection.mutable.Map[String, PageLocalState[Boolean]] = + scala.collection.mutable.Map.empty + + private def openStateFor(id: String): PageLocalState[Boolean] = + openStates.getOrElseUpdate(id, new PageLocalState[Boolean](s"DropdownMenu[$id]")(false) {}) + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Rendering + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private def renderItem[Env, Action](item: Item[Env, Action]): WidgetEAS[Env, Action, Boolean] = + if item._isSeparator then + div(O.DropdownMenu.Separator, Widget.raw.htmlAttr("role", "separator")) + else + div( + O.DropdownMenu.Item.optMods(_.Disabled -> item._isDisabled), + Widget.raw.htmlAttr("role", "menuitem"), + Widget.raw.htmlAttr("tabindex", "-1"), + Widget.when(item._isDisabled)(Widget.raw.htmlAttr("aria-disabled", "true")), + item._icon.map(icon => span(O.DropdownMenu.ItemIcon, icon.sm)).getOrElse(Widget.empty), + span(item._label), + Widget.when(!item._isDisabled)( + onClick.as[Action, Boolean].handle { (s, rh) => item._onSelect(rh) *> s.set(false) }, + ), + ) + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Keyboard + focus (driven by real DOM events → safe without a mount hook) + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private val panelDataAttr: String = "data-oxy-dd" + private def triggerElemId(id: String): String = s"oxy-dd-trigger-$id" + + private def openAndFocus(st: WidgetState[Boolean], id: String): UIO[Unit] = + st.set(true) *> ZIO.succeed { afterRender(() => focusItem(id, 0)) } + + private def panelKeyDown(st: WidgetState[Boolean], e: dom.KeyboardEvent, id: String): UIO[Unit] = + e.key match { + case "ArrowDown" => e.preventDefault(); ZIO.succeed(moveFocus(id, +1)) + case "ArrowUp" => e.preventDefault(); ZIO.succeed(moveFocus(id, -1)) + case "Home" => e.preventDefault(); ZIO.succeed(focusItem(id, 0)) + case "End" => e.preventDefault(); ZIO.succeed(focusItem(id, Int.MaxValue)) + case "Enter" | " " => + e.preventDefault() + ZIO.succeed(clickActive()) + case "Escape" => + st.set(false) *> ZIO.succeed(focusTrigger(id)) + case "Tab" => + st.set(false) + case _ => + ZIO.unit + } + + private def afterRender(f: () => Unit): Unit = + dom.window.requestAnimationFrame(_ => f()): Unit + + private def enabledItems(id: String): Seq[HTMLElement] = { + val nodes = dom.document.querySelectorAll(s"[$panelDataAttr='$id'] [role='menuitem']:not([aria-disabled='true'])") + (0 until nodes.length).map(i => nodes(i).asInstanceOf[HTMLElement]) + } + + /** Focus item at `idx` (clamped); `Int.MaxValue` = last. */ + private def focusItem(id: String, idx: Int): Unit = { + val items = enabledItems(id) + if items.nonEmpty then items(idx.max(0).min(items.size - 1)).focus() + } + + private def moveFocus(id: String, delta: Int): Unit = { + val items = enabledItems(id) + if items.nonEmpty then { + val active = dom.document.activeElement + val current = items.indexWhere(_ eq active) + val next = if current < 0 then (if delta > 0 then 0 else items.size - 1) else Math.floorMod(current + delta, items.size) + items(next).focus() + } + } + + private def clickActive(): Unit = + dom.document.activeElement match { + case el: HTMLElement => el.click() + case _ => () + } + + private def focusTrigger(id: String): Unit = + afterRender { () => + dom.document.getElementById(triggerElemId(id)) match { + case el: HTMLElement => el.focus() + case _ => () + } + } + +} diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/component/TopBar.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/component/TopBar.scala index 3599e872..af219489 100644 --- a/modules/ui/web/src/main/scala/oxygen/ui/web/component/TopBar.scala +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/component/TopBar.scala @@ -22,6 +22,9 @@ final case class TopBar[-Env, +Action, -StateGet, +StateSet <: StateGet]( private val _cache: TopBar.Cache, private val _left: Seq[TopBar.Item[Env, Action, StateGet, StateSet]], private val _right: Seq[TopBar.Item[Env, Action, StateGet, StateSet]], + private val _nav: Seq[DropdownMenu.Item[Env, Action]] = Nil, + private val _moreLabel: String = "More", + private val _moreId: String = "topbar-overflow", ) extends PWidget.Deferred[Env, Action, StateGet, StateSet] { import TopBar.* @@ -94,6 +97,37 @@ final case class TopBar[-Env, +Action, -StateGet, +StateSet <: StateGet]( ): TopBar[Env2, Action2, StateGet2, StateSet2] = copy(_right = _right ++ addChildren.flatten) + /** + * Responsive nav items (OXY-151). Shown inline (next to the left slot) at `>= md`, and auto-collapsed + * into a single overflow "More" [[DropdownMenu]] below `md` — no JS/`matchMedia`, the swap is pure CSS + * (see [[TopBar.responsiveSheet]], registered via [[oxygen.ui.web.defaults.coreOxygenStyleSheets]]). + * + * Items are typed [[DropdownMenu.Item]]s (label / icon / `onClickPush` / `onSelect` / `disabled`), the + * exact same model the overflow panel renders — so nothing is duplicated between the two layouts. + * + * {{{ + * TopBar.empty.brand + * .left(TopBar.item.index("MyApp").onClickPush(HomePage)) + * .nav( + * TopBar.menuItem("Home").onClickPush(HomePage), + * TopBar.menuItem("Products").withIcon(Icon.grid).onClickPush(ProductsPage), + * TopBar.menuItem("About").onClickPush(AboutPage), + * ) + * }}} + */ + def nav[Env2 <: Env, Action2 >: Action, StateGet2 <: StateGet, StateSet2 >: StateSet <: StateGet2]( + addItems: DropdownMenu.Item[Env2, Action2]*, + ): TopBar[Env2, Action2, StateGet2, StateSet2] = + copy(_nav = _nav ++ addItems) + + /** Label for the collapsed overflow menu trigger (default `"More"`). */ + def moreLabel(label: String): TopBar[Env, Action, StateGet, StateSet] = + copy(_moreLabel = label) + + /** Stable id for the overflow menu's open/closed state (must be unique per call site). */ + def moreId(id: String): TopBar[Env, Action, StateGet, StateSet] = + copy(_moreId = id) + override protected def build: PWidget[Env, Action, StateGet, StateSet] = { import oxygen.ui.web.create.{height as heightAttr, width as widthAttr} val c = _cache @@ -120,13 +154,73 @@ final case class TopBar[-Env, +Action, -StateGet, +StateSet <: StateGet]( flexGrow := 1, flexShrink := 0, ) + + // Responsive nav (OXY-151): both layouts are rendered; CSS media queries show exactly one. + // - `.oxy-topbar-nav` : inline items, hidden below `md` + // - `.oxy-topbar-overflow` : collapsed "More" dropdown, hidden at/above `md` + val navInline: PWidget[Env, Action, Any, Nothing] = + Widget.when(_nav.nonEmpty) { + div( + Widget.`class`("oxy-topbar-nav"), + heightAttr := 100.pct, + display.flex, + alignItems.center, + flexShrink := "0", + Widget.fragment(_nav.map(navItemInline(_, c))), + ) + } + val navOverflow: PWidget[Env, Action, Any, Nothing] = + Widget.when(_nav.nonEmpty) { + div( + Widget.`class`("oxy-topbar-overflow"), + heightAttr := 100.pct, + display.flex, + alignItems.center, + flexShrink := "0", + overflowMenu(c), + ) + } + bar( shrinkSection(_left.map(_.withBarColors(c))*), + navInline, + navOverflow, growSection, - shrinkSection(_right.map(_.withBarColors(c))*), + shrinkSection(_right.map(_.withBarColors(c, alignEnd = true))*), ) } + private def navItemInline(item: DropdownMenu.Item[Env, Action], c: Cache): PWidget[Env, Action, Any, Nothing] = + if item.barSeparator then Widget.empty + else if item.barDisabled then + TopBar.itemWidget(c)( + cursor := "not-allowed", + opacity := "0.55", + item.barIcon.map(_.md).getOrElse(Widget.empty), + Widget.when(item.barLabel.nonEmpty)(span(item.barLabel)), + ) + else + TopBar.itemWidget(c)( + item.barIcon.map(_.md).getOrElse(Widget.empty), + Widget.when(item.barLabel.nonEmpty)(span(item.barLabel)), + gap := S.spacing._2, + onClick.a[Action].handle(rh => item.barSelect(rh)), + ) + + private def overflowMenu(c: Cache): PWidget[Env, Action, Any, Nothing] = + DropdownMenu(_moreId, span(_moreLabel)) + .items(_nav*) + .caret + .trigger( + create.height := 100.pct, + padding := "0 1rem", + fontSize := S.fontSize._5, + color := c.itemFg, + fontWeight := S.fontWeight.medium, + backgroundColor.dynamic.hover := c.itemHover, + backgroundColor.dynamic.hoverActive := c.itemActive, + ) + } object TopBar extends WidgetTypes[TopBar] { @@ -135,19 +229,89 @@ object TopBar extends WidgetTypes[TopBar] { val item: TopBar.Item.Const = TopBar.Item.empty + /** A menu entry for a [[Item.dropdown]] (delegates to [[DropdownMenu.item]]). */ + def menuItem(label: String): DropdownMenu.Item.Const = DropdownMenu.item(label) + + /** A separator line inside a [[Item.dropdown]] menu. */ + val menuSeparator: DropdownMenu.Item.Const = DropdownMenu.separator + final case class Item[-Env, +Action, -StateGet, +StateSet <: StateGet] private ( private val _children: Growable[Widget.Polymorphic[Env, Action, StateGet, StateSet]], private val _bar: Cache, + private val _dropdownId: Option[String], + private val _triggerContent: Growable[Widget], + private val _menuItems: Growable[DropdownMenu.Item[Env, Action]], + private val _alignEnd: Boolean, ) extends PWidget.Deferred[Env, Action, StateGet, StateSet] { private lazy val _built: Widget.Polymorphic[Env, Action, StateGet, StateSet] = - TopBar.itemWidget(_bar).appendChildren(_children) + _dropdownId match { + case Some(id) => + // Trigger reuses the bar-item chrome (height / colors / hover) + caret; panel is the shared DropdownMenu. + DropdownMenu(id, _triggerContent.to[Seq]*) + .items(_menuItems.to[Seq]*) + .align(if _alignEnd then DropdownMenu.Align.End else DropdownMenu.Align.Start) + .caret + .trigger( + create.height := 100.pct, + padding := "0 1rem", + fontSize := S.fontSize._5, + color := _bar.itemFg, + fontWeight := S.fontWeight.medium, + backgroundColor.dynamic.hover := _bar.itemHover, + backgroundColor.dynamic.hoverActive := _bar.itemActive, + ) + case None => + TopBar.itemWidget(_bar).appendChildren(_children) + } override protected def build: PWidget[Env, Action, StateGet, StateSet] = _built private[TopBar] def withBarColors(c: Cache): Item[Env, Action, StateGet, StateSet] = copy(_bar = c) + private[TopBar] def withBarColors(c: Cache, alignEnd: Boolean): Item[Env, Action, StateGet, StateSet] = + copy(_bar = c, _alignEnd = alignEnd) + + /** + * Turn this item into a dropdown / popup menu (OXY-152). `id` must be stable + unique per call site + * (keys the menu's internal open/closed state). Built on the shared [[DropdownMenu]] component so the + * panel behaviour (scrim, keyboard, a11y) is consistent everywhere. + * + * {{{ + * TopBar.item.dropdown("nav-products", "Products")( + * TopBar.menuItem("Overview").onClickPush(OverviewPage.nav()), + * TopBar.menuItem("Pricing").onClickPush(PricingPage.nav()), + * TopBar.menuSeparator, + * ) + * }}} + */ + def dropdown[Env2 <: Env, Action2 >: Action, StateGet2 <: StateGet, StateSet2 >: StateSet <: StateGet2]( + id: String, + label: String, + )( + addItems: DropdownMenu.Item[Env2, Action2]*, + ): Item[Env2, Action2, StateGet2, StateSet2] = + copy( + _dropdownId = id.some, + _triggerContent = Growable.single(span(label)), + _menuItems = _menuItems ++ Growable.many(addItems), + ) + + /** Dropdown with a leading icon on the trigger. */ + def dropdownWithIcon[Env2 <: Env, Action2 >: Action, StateGet2 <: StateGet, StateSet2 >: StateSet <: StateGet2]( + id: String, + icon: Icon, + label: String, + )( + addItems: DropdownMenu.Item[Env2, Action2]*, + ): Item[Env2, Action2, StateGet2, StateSet2] = + copy( + _dropdownId = id.some, + _triggerContent = Growable.many(Seq(icon.md, span(label))), + _menuItems = _menuItems ++ Growable.many(addItems), + ) + def apply[Env2 <: Env, Action2 >: Action, StateGet2 <: StateGet, StateSet2 >: StateSet <: StateGet2]( addChildren: PWidget[Env2, Action2, StateGet2, StateSet2]*, ): TopBar.Item[Env2, Action2, StateGet2, StateSet2] = @@ -187,7 +351,7 @@ object TopBar extends WidgetTypes[TopBar] { } object Item extends WidgetTypes[TopBar.Item] { - val empty: TopBar.Item.Const = Item(Growable.empty, Cache.default) + val empty: TopBar.Item.Const = Item(Growable.empty, Cache.default, None, Growable.empty, Growable.empty, false) def apply(text: String): TopBar.Item.Const = empty.apply(text) @@ -217,6 +381,23 @@ object TopBar extends WidgetTypes[TopBar] { ) } + /** + * OXY-151: auto-swap the responsive [[TopBar.nav]] items between inline (desktop) and a collapsed + * "More" overflow menu (mobile) purely via CSS `@media` — no JS/`matchMedia`, so it is SSR/hydration + * safe (no FOUC). Registered by [[oxygen.ui.web.defaults.coreOxygenStyleSheets]]. + */ + val responsiveSheet: StyleSheet = + MediaCSS.styleSheet("topbar-responsive")( + MediaCSS.mdUp( + """.oxy-topbar-overflow { display: none !important; }""", + ), + MediaCSS.belowMd( + """|.oxy-topbar-nav { display: none !important; } + |.oxy-topbar-overflow { display: flex !important; } + |""".stripMargin, + ), + ) + private def unsafeUrl(url: String): URL = URL.decode(url) match case Right(url) => url case Left(error) => throw new RuntimeException(s"Invalid URL [$url]: $error") diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/component/package.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/component/package.scala index 7febe2e3..f3b6f97c 100644 --- a/modules/ui/web/src/main/scala/oxygen/ui/web/component/package.scala +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/component/package.scala @@ -5,7 +5,6 @@ package oxygen.ui.web // : - CheckBox // : - ContextMenu // : - DateTimeSelect -// : - DropdownMenu // : - Icon // : - Link // : - RadioButtons diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/create/OxygenStyleSheet.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/create/OxygenStyleSheet.scala index fae676df..36d9f007 100644 --- a/modules/ui/web/src/main/scala/oxygen/ui/web/create/OxygenStyleSheet.scala +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/create/OxygenStyleSheet.scala @@ -343,6 +343,143 @@ object OxygenStyleSheet extends StyleSheetBuilder { } + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // DropdownMenu + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Action / nav popup menu (OXY-152). A `position: relative` anchor holding a trigger, a full-viewport + * transparent scrim (outside-click), and an absolutely-positioned panel of `menuitem`s. + * Consumed by [[oxygen.ui.web.component.DropdownMenu]] and TopBar dropdown items. + */ + object DropdownMenu extends Class("dropdown-menu") { dm => + + selector( + position.relative, + display.inlineFlex, + alignItems.stretch, + ) + + object Trigger extends dm.Class("trigger") { + selector( + display.inlineFlex, + alignItems.center, + gap := S.spacing._2, + cursor.pointer, + userSelect.none, + ) + Trigger.pc("focus-visible")( + outline := s"2px solid ${S.color.fg.focus}", + Widget.raw.css("outline-offset", "-2px"), + ) + } + + object Caret extends dm.Class("caret") { + selector( + display.inlineFlex, + alignItems.center, + transition := "transform 0.15s ease", + ) + } + + object Scrim extends dm.Class("scrim") { + selector( + position.fixed, + top := 0, + left := 0, + width := 100.vw, + height := 100.vh, + backgroundColor := "transparent", + zIndex := ZIndices.dropdownMenuScrim, + ) + } + + object Panel extends dm.Class("panel") { p => + selector( + position.absolute, + top := 100.pct, + marginTop := S.spacing._1, + minWidth := 12.rem, + maxWidth := 20.rem, + maxHeight := 70.vh, + overflowY.auto, + display.flex, + flexDirection.column, + padding := css(S.spacing._1, 0.px), + backgroundColor := S.color.bg.layerOne, + border := s"1px solid ${S.color.fg.subtle}", + borderRadius := S.borderRadius._3, + boxShadow := "0 12px 32px rgba(0,0,0,0.24)", + zIndex := ZIndices.dropdownMenuPanel, + animation := "oxy-fade-in var(--oxy-motion-duration-fast) var(--oxy-motion-easing-enter) both", + ) + object AlignStart extends p.Modifier("align-start") { + selector(left := 0) + } + object AlignEnd extends p.Modifier("align-end") { + selector(right := 0) + } + } + + object Item extends dm.Class("item") { i => + selector( + display.flex, + alignItems.center, + gap := S.spacing._2, + width := 100.pct, + padding := css(S.spacing._2, S.spacing._3), + cursor.pointer, + userSelect.none, + whiteSpace.nowrap, + color := S.color.fg.default, + fontSize := S.fontSize._3, + backgroundColor := "transparent", + ) + Item.pc("hover")( + backgroundColor := S.color.bg.layerTwo, + ) + Item.pc("focus")( + backgroundColor := S.color.bg.layerTwo, + outline := "none", + ) + object Disabled extends i.Modifier("disabled") { + selector( + color := S.color.fg.subtle, + cursor := "not-allowed", + opacity := "0.55", + ) + } + } + + object ItemIcon extends dm.Class("item-icon") { + selector( + display.inlineFlex, + alignItems.center, + color := S.color.fg.moderate, + ) + } + + object Separator extends dm.Class("separator") { + selector( + height := 1.px, + margin := css(S.spacing._1, 0.px), + backgroundColor := S.color.fg.subtle, + opacity := "0.5", + ) + } + + object Open extends dm.Modifier("open") + + // Cross-cutting rules (objects above already initialized). + ((DropdownMenu & Open) >> Caret)( + transform := "rotate(180deg)", + ) + ((Item & Item.Disabled).pc("hover"))( + backgroundColor := "transparent", + ) + + } + ////////////////////////////////////////////////////////////////////////////////////////////////////// // Table ////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/create/ZIndices.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/create/ZIndices.scala index fc0b3adf..3c4d8a22 100644 --- a/modules/ui/web/src/main/scala/oxygen/ui/web/create/ZIndices.scala +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/create/ZIndices.scala @@ -8,6 +8,11 @@ object ZIndices { val _4: Int = 40 val _5: Int = 50 + // OXY-152: dropdown / popup menus. Below modals so a modal always covers an open menu, + // above normal page content. Panel sits one above its own outside-click scrim. + val dropdownMenuScrim: Int = 8_000 + val dropdownMenuPanel: Int = 8_001 + val modalBehindPageMessages: Int = 9_000 val pageMessages: Int = 10_000 val modalInFrontOfPageMessages: Int = 11_000 diff --git a/modules/ui/web/src/main/scala/oxygen/ui/web/defaults/coreOxygenStyleSheets.scala b/modules/ui/web/src/main/scala/oxygen/ui/web/defaults/coreOxygenStyleSheets.scala index ea4b0eac..f9bc8149 100644 --- a/modules/ui/web/src/main/scala/oxygen/ui/web/defaults/coreOxygenStyleSheets.scala +++ b/modules/ui/web/src/main/scala/oxygen/ui/web/defaults/coreOxygenStyleSheets.scala @@ -1,6 +1,6 @@ package oxygen.ui.web.defaults -import oxygen.ui.web.component.{ColumnsStyle, SortableList, Tooltip} +import oxygen.ui.web.component.{ColumnsStyle, SortableList, Tooltip, TopBar} import oxygen.ui.web.create.{Motion, OxygenStyleSheet, StyleSheet} import oxygen.ui.web.layout.HolyGrail import scala.collection.immutable.ArraySeq @@ -26,6 +26,7 @@ val coreOxygenStyleSheets: ArraySeq[StyleSheet] = OxygenStyleSheet.compiled, ColumnsStyle.sheet, HolyGrail.responsiveSheet, + TopBar.responsiveSheet, Motion.sheet, Tooltip.sheet, SortableList.sheet, diff --git a/modules/ui/web/src/test/scala/oxygen/ui/web/style/OxygenColorSystemSpec.scala b/modules/ui/web/src/test/scala/oxygen/ui/web/style/OxygenColorSystemSpec.scala index fcd10d5c..4b0a7434 100644 --- a/modules/ui/web/src/test/scala/oxygen/ui/web/style/OxygenColorSystemSpec.scala +++ b/modules/ui/web/src/test/scala/oxygen/ui/web/style/OxygenColorSystemSpec.scala @@ -361,6 +361,16 @@ object OxygenColorSystemSpec extends OxygenSpecDefault { assertTrue(sb.bg.nonEmpty) && assertTrue(!tb.bg.contains("#") || tb.bg.startsWith("var(") || tb.bg.contains("--")) }, + test("TopBar.responsiveSheet swaps inline nav and overflow menu at md (OXY-151)") { + val css = TopBar.responsiveSheet.innerHTML + // Desktop (>= md): overflow "More" hidden. + assertTrue(css.contains("@media (min-width: 768px)")) && + assertTrue(css.contains(".oxy-topbar-overflow { display: none !important; }")) && + // Mobile (< md): inline nav hidden, overflow shown. + assertTrue(css.contains("@media (max-width: 767px)")) && + assertTrue(css.contains(".oxy-topbar-nav { display: none !important; }")) && + assertTrue(css.contains(".oxy-topbar-overflow { display: flex !important; }")) + }, ), suite("Contrast (W1-T09)")( test("black on white meets AA normal") { diff --git a/report/OXY-151.md b/report/OXY-151.md new file mode 100644 index 00000000..3991de38 --- /dev/null +++ b/report/OXY-151.md @@ -0,0 +1,40 @@ +# OXY-151 — Responsive TopBar overflow "More" menu + +## Step 0 — Ticket triage findings +- **Ticket found:** OXY-151 "Add better support for mobile vs desktop differences" (Task, parent epic OXY-83 `oxygen-ui`). Status: To Do. +- OXY-152 references OXY-151 directly ("the panel should be reusable so OXY-151's mobile overflow `More` menu can share it"). +- **Scope of OXY-151 is broader** than this batch item: it covers hamburger→Drawer shell, MatchMedia `isMobile` helpers, CenteredCard mobile TODO, responsive CSS classes, AND the TopBar overflow `More` menu. This PR focuses on the **TopBar overflow "More" menu that auto-swaps on narrow viewports, reusing the OXY-152 DropdownMenu** (the piece OXY-152 explicitly hands off). Other OXY-151 sub-scopes left for follow-up. +- **No duplicate tickets** found. Keyword search (mobile/overflow/responsive/TopBar/hamburger/More menu) returned only OXY-151 + OXY-152 (plus unrelated trace-id tickets). No overlap beyond the intended OXY-152 handoff. +- **Overlap w/ OXY-152:** intentional reuse — this PR consumes `component/DropdownMenu` rather than duplicating it. +- **Sprint action:** OXY-151 added to active sprint "Sprint #6" (id 167) via editJiraIssue customfield_10020. Verified. + +## Implementation plan / decisions +- Stacked on branch `OXY-152` (worktree `~/dev/repo/worktrees/OXY-151`). +- Approach: CSS-driven responsive swap (no JS matchMedia needed for the swap itself) — render BOTH the full inline items and an overflow "More" DropdownMenu, toggling visibility via media-query CSS classes at the `md` breakpoint. Avoids SSR/hydration FOUC. +- Reuse `DropdownMenu` for the "More" panel. + +## What was implemented (scope: TopBar overflow "More" menu — the OXY-152 handoff piece) +- `TopBar.nav(items: DropdownMenu.Item*)` + `.moreLabel(...)` + `.moreId(...)`. Nav items are typed `DropdownMenu.Item`s (the SAME model the panel renders) — declared once, no duplication. +- `build` now renders BOTH layouts; a pure-CSS `@media` swap picks one: + - `.oxy-topbar-nav` — inline items, hidden below `md`. + - `.oxy-topbar-overflow` — collapsed "More" `DropdownMenu` (reused OXY-152 component), hidden at/above `md`. +- `TopBar.responsiveSheet` (MediaCSS, `mdUp` hides overflow / `belowMd` hides inline + shows overflow), registered in `coreOxygenStyleSheets`. +- Inline items reuse the same typed item via new `private[component]` accessors on `DropdownMenu.Item` (`barLabel/barIcon/barDisabled/barSeparator/barSelect`) — inline click delegates to the item's own `onSelect`. Separators skipped inline, kept in the collapsed menu. +- Breakpoint = `md` (768px), matching `HolyGrail.responsiveSheet`. +- Showcase: new `ResponsiveTopBarPage` (live bar + forced-narrow 360px frame) wired into `ShowcaseLayout` sideNav + `UIMain` routes. +- Docs: `builders.md` "Responsive TopBar (overflow menu)" subsection + updated Mobile-shell WIP row. +- Test: `OxygenColorSystemSpec` — asserts `responsiveSheet` emits the correct md swap rules. + +## Key decisions / assumptions +- CSS-only auto-swap (no JS/`matchMedia`) → SSR/hydration safe, no FOUC. Both layouts in DOM; CSS shows one. (OXY-151 open-Q #3/#6 → chose CSS classes.) +- Reused `DropdownMenu.Item` as the single shared nav-item model rather than inventing a new type, so inline + overflow never diverge (directly satisfies OXY-152's "share the panel" intent). +- Kept scope to the TopBar overflow menu only. OXY-151 is a broader umbrella (hamburger→Drawer side nav, `MatchMedia.isMobile`, CenteredCard mobile TODO) — those are explicitly left as follow-up and noted in the docs WIP row + PR body. +- Known v1 limitation inherited from OXY-152: the panel is `absolute` under the trigger; an ancestor with `overflow:hidden` (e.g. HolyGrail top row) can clip it — portal/`fixed` variant is future work. + +## Verification +- `oxygen-ui-web/compile` ✓ `example-ui-web/compile` ✓ `oxygen-ui-web/test` ✓ (46 passed, incl. new). +- `sbt fmt` run; JGit worktree workaround (`git-worktree-fix.sbt`) applied only to load sbt, then deleted — NOT committed. + +## Final summary +- Ticket pulled into sprint: **OXY-151** → "Sprint #6" (id 167, active). Verified. +- CONFIDENCE: **8/10**. Compiles + tests + fmt all green, follows OXY-152's patterns exactly, CSS approach is low-risk. Deductions: the responsive swap was verified by unit-testing the emitted CSS, not by a real headless-browser breakpoint test (no such harness here); and this delivers one slice of the broader OXY-151 umbrella by design. diff --git a/report/OXY-152.md b/report/OXY-152.md new file mode 100644 index 00000000..3704619d --- /dev/null +++ b/report/OXY-152.md @@ -0,0 +1,77 @@ +# OXY-152 — Add dropdown menu support to TopBar + +## Goal +Reusable action/nav popup-menu for `TopBar` (and future SideBar / OXY-151 overflow), +with scrim + outside-click/Escape close, a11y roles, keyboard roving, and a showcase. + +## Key decisions (open questions resolved) +- **Reusable component**: built `component/DropdownMenu.scala` (per the `component/package.scala` TODO) + rather than a TopBar-private panel. TopBar delegates to it. Removed `DropdownMenu` from the package TODO. +- **Menu-item model**: typed `DropdownMenu.Item` (label, optional icon, `disabled`, `separator`, activation + effect) — NOT arbitrary Widget. Typed model is what lets us emit correct `role=menuitem` + a11y and keyboard + handling. Activation supports `onClickPush` (nav/page/url), `onSelectAction(a)` (raise action), and + `onSelect(effect)` (arbitrary ZIO). +- **Local state**: menu open/closed is component-local via a per-instance `PageLocalState[Boolean]`, keyed by a + caller-supplied **stable `id: String`** and memoized in a registry. This makes the menu self-contained + (`WidgetEA` via `.attach`) so it drops into `TopBar` without changing TopBar's page-state type. Trade-off: + caller must pass a stable id (documented). Chosen over threading `Widget.state[Boolean]` through page state + (Drawer/Dropdown style) because TopBar menus shouldn't pollute page state. +- **Trigger**: click-to-toggle (not hover). Hover-open is fragile for a11y and touch; documented as future. +- **Levels**: one flat level for v1 (no nested submenus). Documented as future. +- **Positioning**: `position: absolute` panel anchored to a `position: relative` trigger wrapper (mirrors the + existing `Dropdown` component, which works under HolyGrail). Full-viewport **fixed transparent scrim** catches + outside clicks (Drawer-style). Alignment start/end (left/right-anchored). zIndex: added + `ZIndices.dropdownMenuScrim/Panel` (8000/8001) — below modals (9000), above content. + Known limitation: an ancestor with `overflow:hidden` between wrapper and viewport could clip the panel; + a portal/`fixed`+measured approach is future work (noted in Q6). Not observed under HolyGrail top row. +- **A11y**: trigger `role=button`, `aria-haspopup=menu`, `aria-expanded`, `tabindex=0`; panel `role=menu`; + items `role=menuitem`, `aria-disabled`. Keyboard: Enter/Space/ArrowDown open; ArrowUp/Down roving (wrap), + Home/End, Enter/Space activate, Escape closes + restores focus to trigger, Tab closes. Focus first item on + open, restore to trigger on Escape — driven via DOM (`data-oxy-dd`/id) + `requestAnimationFrame`. +- **Caret**: optional chevron indicator (default on for TopBar dropdowns), rotates when open. +- **Stylesheet**: added `O.DropdownMenu` (Trigger/Caret/Scrim/Panel/Item[+Disabled]/ItemIcon/Separator/Open) + to `OxygenStyleSheet` (the `O` object), already registered via `coreOxygenStyleSheets`. Theme tokens only. + +## TopBar integration +- `TopBar.Item` gains an optional dropdown (`_dropdownId` + `_menuItems`). When set, the item renders a + `DropdownMenu` whose trigger reuses the bar-item chrome (height/hover/colors) + caret; else unchanged (flat). +- New API: `TopBar.item.dropdown(id)(items*)`, plus `TopBar.menuItem(...)` / `TopBar.menuSeparator` helpers. +- Right-section dropdowns auto-align to the end (via `withBarColors(cache, alignEnd)`). +- Existing flat call sites unchanged (verified `common.scala` / `ShowcaseLayout` still compile). + +## Showcase / docs +- New `DropdownMenuPage` showcase (left nav dropdown, right user-menu dropdown, disabled item, separator, + standalone DropdownMenu). Registered in `UIMain.pages` + `ShowcaseLayout.sideNav`. +- Brief note added to `docs/docs/ui/agent-guide.md` component list. + +## Files touched +- `modules/ui/web/.../component/DropdownMenu.scala` (new) — the component. +- `modules/ui/web/.../create/OxygenStyleSheet.scala` — added `O.DropdownMenu` classes. +- `modules/ui/web/.../create/ZIndices.scala` — `dropdownMenuScrim`/`dropdownMenuPanel`. +- `modules/ui/web/.../component/TopBar.scala` — `Item.dropdown` / `dropdownWithIcon`, `menuItem`/`menuSeparator`, right-align. +- `modules/ui/web/.../component/package.scala` — removed `DropdownMenu` from TODO. +- `example/apps/ui/.../showcase/pages/DropdownMenuPage.scala` (new) — showcase. +- `example/apps/ui/.../UIMain.scala` + `.../showcase/ShowcaseLayout.scala` — registration + nav. +- `docs/docs/ui/agent-guide.md` — component table row. + +## Verification +- `oxygen-ui-web/compile` ✅ `example-ui-web/compile` ✅ `oxygen-ui-web/test` ✅ (45 passed, incl. Shell chrome). +- NOT verified: live browser run (focus/keyboard behaviour reasoned through, not click-tested in a browser). +- Build note: sbt-git's JGit cannot read a git *worktree* (`NoWorkTreeException`); used a temporary + `zz-git-workaround.sbt` overriding `git.*` settings to build, then deleted it (NOT committed). Building this + worktree with sbt again requires re-adding a similar override, or running from a non-worktree checkout. + +## Progress log +- Explored TopBar, Drawer, Dropdown, stylesheet DSL, event/state/attach APIs. Decisions above. +- Implemented component + stylesheet + TopBar integration + showcase + docs. Compiles + tests green. + +## CONFIDENCE SCORE: 7/10 +Solid on: compiles across lib + example app, unit tests pass, no regression to flat TopBar call sites, +typed a11y roles/keyboard/focus wired, reusable component per the package TODO, self-contained state. +Uncertainty: (1) no live browser test — focus-first-on-open / roving / focus-restore rely on +`requestAnimationFrame` + DOM queries and the assumption that `state.set` re-renders before rAF fires +(reasoned correct for scala.js, unverified visually); (2) `absolute` panel could be clipped by an ancestor +`overflow:hidden` in some layouts (documented, portal variant deferred); (3) menu open-state keyed by a +caller-supplied stable `id` — correct but a new ergonomic contract callers must honour. + +