Make the application sidebar responsive

9ea33559726b · AtlantisPleb · · parent 271189b1ed87

Make the application sidebar responsive

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/css/app.css
  • modified lib/openagents_web/components/layouts.ex
  • modified test/openagents_web/live/chat_live_test.exs

Diff

3 files changed, +229 -4

assets/css/app.css modified +91

@@ -246,6 +246,97 @@

246 246
  background: var(--ink-void);
247 247
}
248 248
249
/* The application sidebar is a drawer below the desktop breakpoint and a
250
   collapsible rail above it. The server renders it closed so mobile never
251
   paints an open drawer before JavaScript starts. On desktop, the no-JavaScript
252
   fallback remains visible. */
253
#app-shell > .sidebar {
254
  position: fixed;
255
  inset-block: 0;
256
  inset-inline-start: 0;
257
  z-index: 50;
258
  width: min(280px, calc(100vw - 48px));
259
  padding-inline-start: env(safe-area-inset-left);
260
  visibility: hidden;
261
  transform: translateX(-100%);
262
  transition:
263
    transform 200ms cubic-bezier(0.22, 1, 0.36, 1),
264
    visibility 0s linear 200ms;
265
}
266
267
#app-shell[data-sidebar-open="true"] > .sidebar {
268
  visibility: visible;
269
  transform: translateX(0);
270
  transition-delay: 0s;
271
}
272
273
.sidebar-scrim {
274
  position: fixed;
275
  inset: 0;
276
  z-index: 40;
277
  display: none;
278
  border: 0;
279
  background: rgb(0 0 0 / 58%);
280
}
281
282
#app-shell[data-sidebar-open="true"] > .sidebar-scrim {
283
  display: block;
284
}
285
286
body.sidebar-open {
287
  overflow: hidden;
288
}
289
290
.sidebar-toggle {
291
  flex: none;
292
  min-width: 44px;
293
  min-height: 44px;
294
}
295
296
.sidebar-toggle__collapse {
297
  display: none;
298
}
299
300
.sidebar-toggle[aria-expanded="true"] .sidebar-toggle__open {
301
  display: none;
302
}
303
304
.sidebar-toggle[aria-expanded="true"] .sidebar-toggle__collapse {
305
  display: inline-flex;
306
}
307
308
@media (min-width: 1024px) {
309
  #app-shell > .sidebar {
310
    position: relative;
311
    inset: auto;
312
    z-index: auto;
313
    width: 240px;
314
    padding-inline-start: 0;
315
    visibility: visible;
316
    transform: none;
317
    transition:
318
      width 200ms cubic-bezier(0.22, 1, 0.36, 1),
319
      border-color 200ms ease;
320
  }
321
322
  #app-shell[data-sidebar-initialized="true"][data-sidebar-open="false"] > .sidebar {
323
    width: 0;
324
    border-color: transparent;
325
    visibility: hidden;
326
  }
327
328
  #app-shell > .sidebar-scrim,
329
  #app-shell[data-sidebar-open="true"] > .sidebar-scrim {
330
    display: none;
331
  }
332
}
333
334
@media (prefers-reduced-motion: reduce) {
335
  #app-shell > .sidebar {
336
    transition: none;
337
  }
338
}
339
249 340
.sidebar-header {
250 341
  display: flex;
251 342
  flex: none;
lib/openagents_web/components/layouts.ex modified +130 -2

@@ -75,7 +75,11 @@ defmodule OpenAgentsWeb.Layouts do

75 75
76 76
  def app(assigns) do
77 77
    ~H"""
78
    <div class="h-screen flex overflow-hidden bg-background">
78
    <div
79
      id="app-shell"
80
      class="h-screen flex overflow-hidden bg-background"
81
      phx-hook=".AppSidebar"
82
    >
79 83
      <.sidebar
80 84
        :if={@current_scope}
81 85
        current_scope={@current_scope}

@@ -84,6 +88,16 @@ defmodule OpenAgentsWeb.Layouts do

84 88
        <:extra>{render_slot(@sidebar_extra)}</:extra>
85 89
      </.sidebar>
86 90
91
      <button
92
        :if={@current_scope}
93
        id="sidebar-scrim"
94
        type="button"
95
        class="sidebar-scrim"
96
        aria-label="Close navigation sidebar"
97
        aria-hidden="true"
98
        tabindex="-1"
99
      ></button>
100
87 101
      <div class="flex-1 min-w-0 flex flex-col h-screen">
88 102
        <.openagents_command_bar current_scope={@current_scope} title={@title} subtitle={@subtitle}>
89 103
          <:menu>{render_slot(@title_menu)}</:menu>

@@ -110,6 +124,106 @@ defmodule OpenAgentsWeb.Layouts do

110 124
      </div>
111 125
112 126
      <.flash_group flash={@flash} class="fixed bottom-4 right-4 z-50" />
127
128
      <script :type={Phoenix.LiveView.ColocatedHook} name=".AppSidebar">
129
        export default {
130
          mounted() {
131
            this.desktop = window.matchMedia("(min-width: 1024px)")
132
            this.onClick = event => this.handleClick(event)
133
            this.onKeydown = event => this.handleKeydown(event)
134
            this.onBreakpointChange = () => this.restoreForViewport()
135
136
            this.el.addEventListener("click", this.onClick)
137
            window.addEventListener("keydown", this.onKeydown)
138
            this.desktop.addEventListener("change", this.onBreakpointChange)
139
            this.restoreForViewport()
140
          },
141
142
          updated() {
143
            this.applyState(this.open, {persist: false})
144
          },
145
146
          destroyed() {
147
            this.el.removeEventListener("click", this.onClick)
148
            window.removeEventListener("keydown", this.onKeydown)
149
            this.desktop.removeEventListener("change", this.onBreakpointChange)
150
            document.body.classList.remove("sidebar-open")
151
          },
152
153
          handleClick(event) {
154
            if (event.target.closest("#sidebar-toggle")) {
155
              this.applyState(!this.open, {persist: true, focusSidebar: !this.open})
156
              return
157
            }
158
159
            if (event.target.closest("#sidebar-scrim")) {
160
              this.applyState(false, {persist: false, restoreFocus: true})
161
              return
162
            }
163
164
            if (!this.desktop.matches && event.target.closest("#sidebar a")) {
165
              this.applyState(false, {persist: false})
166
            }
167
          },
168
169
          handleKeydown(event) {
170
            if (event.key === "Escape" && this.open && !this.desktop.matches) {
171
              this.applyState(false, {persist: false, restoreFocus: true})
172
            }
173
          },
174
175
          restoreForViewport() {
176
            const open = this.desktop.matches ? this.desktopPreference() : false
177
            this.applyState(open, {persist: false})
178
          },
179
180
          desktopPreference() {
181
            try {
182
              return window.localStorage.getItem("openagents:sidebar-desktop") !== "closed"
183
            } catch (_error) {
184
              return true
185
            }
186
          },
187
188
          applyState(open, options = {}) {
189
            const sidebar = this.el.querySelector("#sidebar")
190
            const toggle = this.el.querySelector("#sidebar-toggle")
191
            const scrim = this.el.querySelector("#sidebar-scrim")
192
            if (!sidebar || !toggle || !scrim) return
193
194
            this.open = open
195
            this.el.dataset.sidebarInitialized = "true"
196
            this.el.dataset.sidebarOpen = open ? "true" : "false"
197
            sidebar.setAttribute("aria-hidden", open ? "false" : "true")
198
            sidebar.inert = !open
199
            toggle.setAttribute("aria-expanded", open ? "true" : "false")
200
            toggle.setAttribute(
201
              "aria-label",
202
              open ? "Collapse navigation sidebar" : "Open navigation sidebar"
203
            )
204
            toggle.title = open ? "Collapse navigation sidebar" : "Open navigation sidebar"
205
            scrim.setAttribute("aria-hidden", open ? "false" : "true")
206
            document.body.classList.toggle("sidebar-open", open && !this.desktop.matches)
207
208
            if (options.persist && this.desktop.matches) {
209
              try {
210
                window.localStorage.setItem(
211
                  "openagents:sidebar-desktop",
212
                  open ? "open" : "closed"
213
                )
214
              } catch (_error) {}
215
            }
216
217
            if (options.focusSidebar && !this.desktop.matches) {
218
              window.requestAnimationFrame(() => {
219
                sidebar.querySelector("a, button, summary")?.focus()
220
              })
221
            } else if (options.restoreFocus) {
222
              toggle.focus()
223
            }
224
          }
225
        }
226
      </script>
113 227
    </div>
114 228
    """
115 229
  end

@@ -123,6 +237,20 @@ defmodule OpenAgentsWeb.Layouts do

123 237
    ~H"""
124 238
    <header class="flex items-center gap-2 bg-background border-b border-border px-4 h-[52px] shrink-0">
125 239
      <div class="flex flex-1 min-w-0 items-center gap-2">
240
        <UI.button
241
          :if={@current_scope}
242
          id="sidebar-toggle"
243
          class="sidebar-toggle"
244
          variant={:ghost}
245
          size={:sm}
246
          aria-label="Open navigation sidebar"
247
          aria-controls="sidebar"
248
          aria-expanded="false"
249
          title="Open navigation sidebar"
250
        >
251
          <UI.icon name="menu" class="sidebar-toggle__open" />
252
          <UI.icon name="sidebar-collapse-left" class="sidebar-toggle__collapse" />
253
        </UI.button>
126 254
        <%= if !@current_scope do %>
127 255
          <.link navigate={~p"/"} class="btn text-xl text-foreground" data-variant="ghost">
128 256
            OpenAgents

@@ -597,7 +725,7 @@ defmodule OpenAgentsWeb.Layouts do

597 725
      |> assign(:repository_path, assigns.current_scope.sidebar_repository_path)
598 726
599 727
    ~H"""
600
    <aside id="sidebar" class="sidebar hidden lg:flex">
728
    <aside id="sidebar" class="sidebar" aria-hidden="true">
601 729
      <Layouts.sidebar_brand />
602 730
603 731
      <nav class="sidebar-nav" aria-label="OpenAgents surfaces">
test/openagents_web/live/chat_live_test.exs modified +8 -2

@@ -76,9 +76,15 @@ defmodule OpenAgentsWeb.ChatLiveTest do

76 76
    # sidebar the layout owns, so there is exactly one of each.
77 77
    assert has_element?(view, "#sidebar")
78 78
    assert view |> render() |> String.split("<aside") |> length() == 2
79
    refute has_element?(view, "#sidebar-scrim")
79
    assert has_element?(view, "#app-shell")
80
    assert has_element?(view, ~s(#sidebar[aria-hidden="true"]))
81
    assert has_element?(view, ~s(#sidebar-scrim[aria-hidden="true"]))
80 82
    refute has_element?(view, "#mobile-menu")
81
    refute has_element?(view, "#sidebar-toggle")
83
84
    assert has_element?(
85
             view,
86
             ~s(#sidebar-toggle[aria-controls="sidebar"][aria-expanded="false"])
87
           )
82 88
83 89
    # Every destination chat used to carry in its own rail is still reachable,
84 90
    # with an accessible name on each stretched hit target.

This page updates live while a promote is in flight · changelog