Skip to content
Merged
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
25 changes: 5 additions & 20 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -2372,13 +2372,16 @@ img.avatar {
display: block;
}

/* Add scroll-margin to headings so they clear the sticky nav */
/* Add scroll-margin to headings — and the plan's own masthead header — so
they clear the sticky nav. The nav-title control scrolls to the very top
in JS; this keeps a no-JS #plan-header jump landing below the bar too. */
.markdown-rendered h1,
.markdown-rendered h2,
.markdown-rendered h3,
.markdown-rendered h4,
.markdown-rendered h5,
.markdown-rendered h6 {
.markdown-rendered h6,
.page-header--plan {
scroll-margin-top: calc(var(--nav-height) + var(--space-lg));
}

Expand Down Expand Up @@ -4159,24 +4162,6 @@ img.avatar {
background: var(--color-border);
}

/* Unread count pill on a menu row (Notifications), pushed to the trailing
edge so the label stays left-aligned with its siblings. */
.menu__badge {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 0.7rem;
font-weight: 700;
color: var(--color-text-inverse);
background: var(--color-danger);
border-radius: 9999px;
line-height: 1;
}

/* Notifications is a bar control (the bell) whenever there's room; this menu
copy is the phone fallback, revealed only once the bell folds away. */
.menu__item--notifications {
Expand Down
59 changes: 48 additions & 11 deletions engine/app/javascript/controllers/coplan/nav_title_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,27 @@ export default class extends Controller {
static values = { anchor: { type: String, default: "plan-header" } }

connect() {
const anchor = document.getElementById(this.anchorValue)
// No header to track (empty doc, unexpected markup) — stay hidden
// rather than showing a title that never has a home to return to.
if (!anchor) return
// #plan-header is stream-replaced on every live plan update, which would
// strand an observer bound to the old (detached) node. The .plan-masthead
// wrapper around it is never itself a stream target, so watch that: when
// it mutates we re-point the IntersectionObserver at the fresh header and
// re-sync the title copy from it.
this.masthead = document.querySelector(".plan-masthead")
this.textEl = this.element.querySelector(".site-nav__doc-title-text")
this._observeAnchor()

this._observer = new IntersectionObserver(
([entry]) => this._setVisible(!entry.isIntersecting),
// Trip the moment the header clears the sticky nav, not the very top
// of the viewport — otherwise the title would linger under the bar.
{ rootMargin: `-${this._navHeightPx()}px 0px 0px 0px`, threshold: 0 }
)
this._observer.observe(anchor)
if (this.masthead) {
this._mutation = new MutationObserver(() => {
this._observeAnchor()
this._syncTitle()
})
this._mutation.observe(this.masthead, { childList: true, subtree: true, characterData: true })
}
}

disconnect() {
this._observer?.disconnect()
this._mutation?.disconnect()
this._setVisible(false)
}

Expand All @@ -37,8 +42,40 @@ export default class extends Controller {
window.scrollTo({ top: 0, behavior: "smooth" })
}

// (Re)bind the IntersectionObserver to the current header element. Only
// rebuilds when the node actually changed, so routine masthead updates
// (presence, toolbar) don't churn the observer.
_observeAnchor() {
const anchor = document.getElementById(this.anchorValue)
if (!anchor || anchor === this._anchor) return
this._anchor = anchor

this._observer?.disconnect()
this._observer = new IntersectionObserver(
([entry]) => this._setVisible(!entry.isIntersecting),
// Trip the moment the header clears the sticky nav, not the very top
// of the viewport — otherwise the title would linger under the bar.
{ rootMargin: `-${this._navHeightPx()}px 0px 0px 0px`, threshold: 0 }
)
this._observer.observe(anchor)
}

// Keep the sticky copy in step with the live header, so a rename that
// arrives over the wire doesn't leave a stale name pinned to the bar.
_syncTitle() {
const title = this.masthead?.querySelector(".page-header__title")?.textContent?.trim()
if (title && this.textEl && this.textEl.textContent !== title) {
this.textEl.textContent = title
}
}

_setVisible(visible) {
this.element.classList.toggle("site-nav__doc-title--visible", visible)
// Collapsed, it's decorative and must stay out of the tab order; once
// shown it's a real return-to-top control, so expose it to keyboard and
// screen-reader users too.
this.element.setAttribute("aria-hidden", String(!visible))
this.element.tabIndex = visible ? 0 : -1
}

// --nav-height is authored in rem; resolve it to px for rootMargin.
Expand Down
4 changes: 3 additions & 1 deletion engine/app/views/layouts/coplan/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@
<%= link_to coplan.notifications_path, class: "menu__item menu__item--notifications", role: "menuitem" do %>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span>Notifications</span>
<% if unread > 0 %><span class="menu__badge"><%= unread %></span><% end %>
<%# No count here: only #inbox-badge gets live updates, so a
number rendered once would go stale. Unread is signalled
live by the dot on the ☰ button instead. %>
Comment on lines +94 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose unread status to assistive technology

At phone widths, the bell is hidden and this change removes the only textual unread count from the Notifications menu item. The replacement is solely .site-nav__menu-btn::after, a visual CSS dot on a button whose accessible label remains "Menu", so screen-reader users receive no indication that unread notifications exist or that the count changed. Keep a live, visually hidden status tied to #inbox-badge, or update the menu button or link's accessible label.

Useful? React with 👍 / 👎.

<% end %>
<div class="menu__separator" role="separator"></div>
<%= link_to coplan.profile_path(current_user.username.presence || current_user.id), class: "menu__item", role: "menuitem" do %>
Expand Down
Loading