Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion docs/docs/ui/builders.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
111 changes: 111 additions & 0 deletions modules/ui/web/src/main/scala/oxygen/ui/web/component/TopBar.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down Expand Up @@ -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
Expand All @@ -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] {

Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,6 +26,7 @@ val coreOxygenStyleSheets: ArraySeq[StyleSheet] =
OxygenStyleSheet.compiled,
ColumnsStyle.sheet,
HolyGrail.responsiveSheet,
TopBar.responsiveSheet,
Motion.sheet,
Tooltip.sheet,
SortableList.sheet,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
40 changes: 40 additions & 0 deletions report/OXY-151.md
Original file line number Diff line number Diff line change
@@ -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.
Loading