From c529f24b9d31d4af4113240e682ba6b11dfd4801 Mon Sep 17 00:00:00 2001 From: Kalin Rudnicki Date: Thu, 13 Aug 2026 22:06:07 -0600 Subject: [PATCH 1/2] OXY-151: responsive TopBar overflow "More" menu Collapse TopBar nav items into a single overflow "More" dropdown below `md`, reusing the OXY-152 DropdownMenu component (the handoff OXY-152 was built for). The inline<->overflow swap is pure CSS `@media` (no JS / matchMedia), so it is SSR/hydration safe. - TopBar.nav(...) / .moreLabel / .moreId; nav items are typed DropdownMenu.Item so inline + collapsed layouts never diverge. - TopBar.responsiveSheet registered in coreOxygenStyleSheets. - package-private accessors on DropdownMenu.Item for inline rendering. - Showcase: ResponsiveTopBarPage (+ sideNav + routes); builders.md docs. - Test: responsiveSheet emits the correct md swap rules. Scope: TopBar overflow only; the rest of OXY-151 (hamburger->Drawer side nav, MatchMedia.isMobile, CenteredCard mobile) remains follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011YxWKdsz97QT9BD7AdpSq6 --- docs/docs/ui/builders.md | 23 +++- .../main/scala/oxygen/example/ui/UIMain.scala | 1 + .../ui/page/showcase/ShowcaseLayout.scala | 1 + .../showcase/pages/ResponsiveTopBarPage.scala | 72 ++++++++++++ .../ui/web/component/DropdownMenu.scala | 8 ++ .../oxygen/ui/web/component/TopBar.scala | 111 ++++++++++++++++++ .../web/defaults/coreOxygenStyleSheets.scala | 3 +- .../ui/web/style/OxygenColorSystemSpec.scala | 10 ++ report/OXY-151.md | 40 +++++++ 9 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/ResponsiveTopBarPage.scala create mode 100644 report/OXY-151.md 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 7b5ecdde..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 @@ -66,6 +66,7 @@ object UIMain extends PageApp[UIMain.Env] { 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 cda4c518..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 @@ -57,6 +57,7 @@ object ShowcaseLayout { 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/ResponsiveTopBarPage.scala b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/ResponsiveTopBarPage.scala new file mode 100644 index 00000000..910a8b1c --- /dev/null +++ b/example/apps/ui/src/main/scala/oxygen/example/ui/page/showcase/pages/ResponsiveTopBarPage.scala @@ -0,0 +1,72 @@ +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: TopBar.Const = + TopBar.empty + .brand + .barHeight(52.px) + .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("responsive-user", 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, + ) + + 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, + ), + 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 index 363e31ec..97ab542a 100644 --- 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 @@ -184,6 +184,14 @@ object DropdownMenu { 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] 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 43caebfc..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, 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] { @@ -287,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/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. From 9b178e9436d902badb2170093cee9e2a9d5d9b4a Mon Sep 17 00:00:00 2001 From: Kalin Rudnicki Date: Thu, 13 Aug 2026 23:51:01 -0600 Subject: [PATCH 2/2] OXY-151: fix duplicate DropdownMenu ids in ResponsiveTopBarPage showcase Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011YxWKdsz97QT9BD7AdpSq6 --- .../ui/page/showcase/pages/ResponsiveTopBarPage.scala | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index 910a8b1c..c1df083b 100644 --- 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 @@ -15,10 +15,11 @@ object ResponsiveTopBarPage extends ShowcaseLayout.SimplePage { * 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: TopBar.Const = + 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()), ) @@ -32,7 +33,7 @@ object ResponsiveTopBarPage extends ShowcaseLayout.SimplePage { TopBar.menuItem("Enterprise (soon)").disabled, ) .right( - TopBar.item.dropdownWithIcon("responsive-user", Icon.user, "Jane")( + 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")), @@ -47,7 +48,7 @@ object ResponsiveTopBarPage extends ShowcaseLayout.SimplePage { border := s"1px solid ${S.color.fg.subtle}", borderRadius := S.borderRadius._3, overflow.visible, - demoBar, + demoBar("narrow"), ) override def body: Widget = @@ -63,7 +64,7 @@ object ResponsiveTopBarPage extends ShowcaseLayout.SimplePage { borderRadius := S.borderRadius._3, overflow.visible, marginBottom := S.spacing._6, - demoBar, + 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."),