Basic mobile support #67

Closed
opened 2026-04-07 17:32:08 +00:00 by maxtkc · 0 comments
Owner

Summary

Adopt a mobile layout modeled on Google Maps: full-screen map with a fixed navbar at top, a map tool bar just below it, and the right panel becoming a draggable bottom sheet that snaps to three heights (peek → half → full). The existing DaisyUI radio tab structure works naturally — in peek mode only the tab labels are visible, forming the bottom dock; pulling the sheet up reveals content. No new UI library is needed; a ~120-line BottomSheetController handles the snap logic. The breakpoint is Tailwind's md (768px): everything below is mobile, everything at or above is the current desktop layout.

Note on "switch to Browse on map click": This is already implemented via setupNavigationTabSwitching() in src/index.ts:291. Verify it feels correct during manual testing and add other tab-switch cases as encountered.

Note on "move stop" residue: No explicit "Move Stop" button or mode exists in the codebase. Stop dragging is implemented via mousedown/mousemove/mouseup in interaction-handler.ts — touch devices don't fire those events, so stop dragging is automatically disabled on mobile with no code changes needed.

Relevant Context

  • src/index.html:37–38.app-container with grid-cols-[1fr_650px] (or the CSS var variant from #35); needs to collapse to single column on mobile
  • src/index.html:39–326 — Navbar; needs responsive logo, mobile search slot, icon-only buttons on small screens
  • src/index.html:332–392#map-controls floating overlay; becomes a horizontal bar below the navbar on mobile
  • src/index.html:395–551.right-panel with DaisyUI radio tabs; becomes the bottom sheet on mobile
  • src/index.html:399–406#panel-resizer (desktop-only drag handle from #35); must be hidden on mobile
  • src/styles/main.css — add one @media (max-width: 767px) block for all bottom sheet CSS
  • src/modules/search-controller.ts:33–35 — targets #map-search by ID; needs to also wire #mobile-search
  • src/modules/tab-manager.ts:13switchToTab(tabName) triggers tab radio changes; BottomSheetController listens to tab changes to expand the sheet
  • src/index.ts — GTFSEditor constructor; new module instantiated here

Phase 1: HTML & CSS Skeleton

Goal: Make the layout structurally correct on mobile with pure HTML/CSS changes — no interactive JS yet. At the end of this phase, a mobile viewport shows the map full-screen, a compact navbar, a horizontal tool bar, and a fixed 56px strip at the bottom showing the Browse/Files/Changes tab labels.

Navbar changes (src/index.html)

  • In navbar-start, wrap the branding text div (lines 47–57) in <div class="flex-col hidden md:flex"> so only the logo avatar shows on mobile
  • Add a <div class="navbar-center flex md:hidden"> containing #mobile-search:
    <div class="navbar-center flex md:hidden flex-1 px-2">
      <input type="text" id="mobile-search" placeholder="Search"
             class="input input-sm w-full" />
    </div>
    
  • In the Load button (line 164), wrap the "Load" text in <span class="hidden md:inline">Load</span> so only the icon shows on mobile
  • In the Export button (line 307), wrap the "Export" text in <span class="hidden md:inline">Export</span>
  • Theme toggle, About, Undo/Redo buttons are icon-only already — no changes needed

Right panel changes (src/index.html)

  • Add id="right-panel" to .right-panel div (currently has no ID; needed for JS to reference it)
  • Hide #panel-resizer on mobile: add hidden md:block to its class list (line 401)
  • Add a mobile drag handle as the first child of .right-panel (before #panel-resizer):
    <div id="sheet-drag-handle" class="md:hidden flex justify-center items-center py-2 cursor-grab active:cursor-grabbing flex-shrink-0">
      <div class="w-10 h-1 rounded-full bg-base-content/20"></div>
    </div>
    

Map controls changes (src/index.html)

  • On #map-controls (line 333), replace absolute top-4 right-4 flex-col items-end space-y-2 with responsive classes: absolute top-2 right-2 md:top-4 md:right-4 flex flex-row md:flex-col items-center md:items-end gap-2 md:space-y-2
  • On the search card (line 338), add hidden md:block — search is hidden in the overlay on mobile (it moves to the navbar)
  • On the tools card (line 348), remove shadow-lg on mobile if desired — or leave it (left as-is)

CSS (src/styles/main.css)

  • Add a mobile block after the existing .app-container rule:
    @media (max-width: 767px) {
      .app-container {
        grid-template-columns: 1fr;
      }
    
      #right-panel {
        position: fixed;
        bottom: 0;
        left: 0;
        right: 0;
        height: var(--sheet-height, 56px);
        z-index: 50;
        border-left: none;
        border-top: 1px solid oklch(var(--b3));
        border-radius: 1rem 1rem 0 0;
        transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
        overflow: hidden;
      }
    
      /* Only allow content scroll in full-screen state */
      #right-panel .tab-content {
        overflow-y: hidden;
      }
    
      #right-panel.sheet-full .tab-content {
        overflow-y: auto;
      }
    }
    

Gotchas:

  • The transition: height on #right-panel conflicts with drag (during drag we should suppress the transition and re-enable it on snap). BottomSheetController in Phase 2 removes the transition class during drag and adds it back on release.
  • The .app-container CSS variable rule from plan #35 (grid-template-columns: 1fr var(--panel-width, 650px)) is overridden cleanly by the media query above.
  • oklch(var(--b3)) is the DaisyUI v5 way to reference theme colors in plain CSS.

Phase 2: BottomSheetController + SearchController Update + Wiring

Goal: Make the bottom sheet interactive — draggable with 3 snap heights — and wire up the mobile search input.

Snap heights

peek: 56px        — tab labels only visible (the "bottom dock")
half: 45vh        — partial content view, no scrolling
full: 92vh        — full content, scrolling enabled

Create src/modules/bottom-sheet.ts

  • Implement BottomSheetController class:
import { TabManager } from './tab-manager';

type Snap = 'peek' | 'half' | 'full';

const PEEK_PX = 56;
const HALF_VH = 0.45;
const FULL_VH = 0.92;

export class BottomSheetController {
  private snap: Snap = 'peek';
  private panel: HTMLElement;

  constructor(panel: HTMLElement, tabManager: TabManager) {
    this.panel = panel;

    // Only activate on mobile
    if (window.innerWidth >= 768) return;

    this.setupDragHandle();
    this.setupTabExpansion(tabManager);
    this.setSnap('peek', false);

    // Re-check on resize (e.g. orientation change)
    window.addEventListener('resize', () => {
      if (window.innerWidth >= 768) {
        panel.style.removeProperty('height');
        panel.classList.remove('sheet-full');
      } else {
        this.setSnap(this.snap, false);
      }
    });
  }

  private setupDragHandle(): void {
    const handle = document.getElementById('sheet-drag-handle');
    if (!handle) return;

    let startY = 0;
    let startHeight = 0;
    let lastY = 0;
    let lastTime = 0;
    let velocity = 0;

    const onStart = (clientY: number) => {
      startY = clientY;
      startHeight = this.panel.getBoundingClientRect().height;
      lastY = clientY;
      lastTime = Date.now();
      velocity = 0;
      this.panel.style.transition = 'none';
    };

    const onMove = (clientY: number) => {
      const now = Date.now();
      const dt = now - lastTime;
      if (dt > 0) velocity = (lastY - clientY) / dt; // px/ms, positive = up
      lastY = clientY;
      lastTime = now;

      const delta = startY - clientY; // positive = dragging up
      const newHeight = Math.min(
        window.innerHeight * FULL_VH,
        Math.max(PEEK_PX, startHeight + delta)
      );
      this.panel.style.height = `${newHeight}px`;
    };

    const onEnd = () => {
      this.panel.style.transition = '';
      const targetSnap = this.resolveSnap(velocity);
      this.setSnap(targetSnap, true);
    };

    // Touch
    handle.addEventListener('touchstart', (e) => onStart(e.touches[0].clientY), { passive: true });
    document.addEventListener('touchmove', (e) => { if (startY) onMove(e.touches[0].clientY); }, { passive: true });
    document.addEventListener('touchend', () => { if (startY) { onEnd(); startY = 0; } });

    // Mouse (for desktop testing)
    handle.addEventListener('mousedown', (e) => { e.preventDefault(); onStart(e.clientY); });
    document.addEventListener('mousemove', (e) => { if (startY) onMove(e.clientY); });
    document.addEventListener('mouseup', () => { if (startY) { onEnd(); startY = 0; } });
  }

  private resolveSnap(velocity: number): Snap {
    const VELOCITY_THRESHOLD = 0.4; // px/ms
    if (velocity > VELOCITY_THRESHOLD) {
      return this.snap === 'peek' ? 'half' : 'full';
    }
    if (velocity < -VELOCITY_THRESHOLD) {
      return this.snap === 'full' ? 'half' : 'peek';
    }
    // Snap to nearest based on current height
    const h = this.panel.getBoundingClientRect().height;
    const halfH = window.innerHeight * HALF_VH;
    const fullH = window.innerHeight * FULL_VH;
    if (h < (PEEK_PX + halfH) / 2) return 'peek';
    if (h < (halfH + fullH) / 2) return 'half';
    return 'full';
  }

  private setSnap(snap: Snap, animate: boolean): void {
    this.snap = snap;
    if (!animate) this.panel.style.transition = 'none';
    const h = snap === 'peek' ? PEEK_PX
            : snap === 'half' ? window.innerHeight * HALF_VH
            : window.innerHeight * FULL_VH;
    this.panel.style.setProperty('--sheet-height', `${h}px`);
    this.panel.style.height = `${h}px`;
    this.panel.classList.toggle('sheet-full', snap === 'full');
    if (!animate) {
      // Re-enable transition after layout settles
      requestAnimationFrame(() => { this.panel.style.transition = ''; });
    }
  }

  private setupTabExpansion(tabManager: TabManager): void {
    // When a tab is activated while the sheet is at peek, expand to half
    tabManager.onTabChange(() => {
      if (this.snap === 'peek') this.setSnap('half', true);
    });
  }

  public expandTo(snap: 'half' | 'full'): void {
    this.setSnap(snap, true);
  }
}

Update src/modules/search-controller.ts

  • Find where #map-search is queried (around line 33–35). After initializing the desktop input, also wire #mobile-search:
    • Extract the event listener setup into a helper private wireInput(input: HTMLInputElement): void
    • Call it for both document.getElementById('map-search') and document.getElementById('mobile-search'), filtering out null
    • When a search result is selected, sync the value to both inputs:
      private syncInputs(value: string): void {
        for (const input of this.inputs) input.value = value;
      }
      

Wire BottomSheetController in src/index.ts

  • Import BottomSheetController from ./modules/bottom-sheet
  • In the GTFSEditor constructor, after tabManager is set up:
    const rightPanel = document.getElementById('right-panel');
    if (rightPanel) new BottomSheetController(rightPanel, this.tabManager);
    
    No need to store as a class property.

Gotchas:

  • onTabChange on TabManager needs to fire for programmatic tab switches (via switchToTab) not just user clicks. Check tab-manager.ts:40–51 — if it only listens to DOM change events, switchToTab (which sets checked directly without dispatching an event) may not trigger it. If so, switchToTab should dispatch a synthetic change event or TabManager needs a callback registration path that switchToTab also calls.
  • During drag, document.body.style.userSelect = 'none' should be set to prevent accidental text selection. Restore on touchend/mouseup.
  • window.innerWidth < 768 is checked at constructor time. If the user resizes from desktop to mobile, the controller won't activate. This is acceptable for a v1 — phone browsers don't typically resize.
  • The PEEK_PX = 56 value must match the actual rendered height of the DaisyUI tab label row. Measure this after implementation and adjust if needed. If DaisyUI tabs render taller, adjust accordingly.

Original Issue

Lets do a few things that make it usable on mobile.

I think we can get away with leaving most things the same, just moving the high level things around.

Lets use Google Maps as a model:

  • Nav bar at the top
    • Logo (small top left corner)
    • search middle
    • buttons next
  • add stop and move stop button bar just underneath nav
  • map behind everything
  • Dock at the bottom allows for choosing browse/files/changes
    • If you click browse, the bottom sheet pops up with the "Home" view
    • If you click files, bottom sheet shows up with the files view
    • Same with changes
    • When you highlight an element, it shows up in the bottom sheet and you switch to browse (we should make sure we switch to browse with web as well)
    • Bottom sheet is closeable

Remember to use DaisyUI. Additionally, if there are things we can change in web that improve the experience and reduce differences between the two, lets do it.

## Summary Adopt a mobile layout modeled on Google Maps: full-screen map with a fixed navbar at top, a map tool bar just below it, and the right panel becoming a draggable bottom sheet that snaps to three heights (peek → half → full). The existing DaisyUI radio tab structure works naturally — in peek mode only the tab labels are visible, forming the bottom dock; pulling the sheet up reveals content. No new UI library is needed; a ~120-line `BottomSheetController` handles the snap logic. The breakpoint is Tailwind's `md` (768px): everything below is mobile, everything at or above is the current desktop layout. **Note on "switch to Browse on map click":** This is already implemented via `setupNavigationTabSwitching()` in `src/index.ts:291`. Verify it feels correct during manual testing and add other tab-switch cases as encountered. **Note on "move stop" residue:** No explicit "Move Stop" button or mode exists in the codebase. Stop dragging is implemented via `mousedown`/`mousemove`/`mouseup` in `interaction-handler.ts` — touch devices don't fire those events, so stop dragging is automatically disabled on mobile with no code changes needed. ## Relevant Context - `src/index.html:37–38` — `.app-container` with `grid-cols-[1fr_650px]` (or the CSS var variant from #35); needs to collapse to single column on mobile - `src/index.html:39–326` — Navbar; needs responsive logo, mobile search slot, icon-only buttons on small screens - `src/index.html:332–392` — `#map-controls` floating overlay; becomes a horizontal bar below the navbar on mobile - `src/index.html:395–551` — `.right-panel` with DaisyUI radio tabs; becomes the bottom sheet on mobile - `src/index.html:399–406` — `#panel-resizer` (desktop-only drag handle from #35); must be hidden on mobile - `src/styles/main.css` — add one `@media (max-width: 767px)` block for all bottom sheet CSS - `src/modules/search-controller.ts:33–35` — targets `#map-search` by ID; needs to also wire `#mobile-search` - `src/modules/tab-manager.ts:13` — `switchToTab(tabName)` triggers tab radio changes; BottomSheetController listens to tab changes to expand the sheet - `src/index.ts` — GTFSEditor constructor; new module instantiated here ## Phase 1: HTML & CSS Skeleton Goal: Make the layout structurally correct on mobile with pure HTML/CSS changes — no interactive JS yet. At the end of this phase, a mobile viewport shows the map full-screen, a compact navbar, a horizontal tool bar, and a fixed 56px strip at the bottom showing the Browse/Files/Changes tab labels. ### Navbar changes (`src/index.html`) - [x] In `navbar-start`, wrap the branding text div (lines 47–57) in `<div class="flex-col hidden md:flex">` so only the logo avatar shows on mobile - [x] Add a `<div class="navbar-center flex md:hidden">` containing `#mobile-search`: ```html <div class="navbar-center flex md:hidden flex-1 px-2"> <input type="text" id="mobile-search" placeholder="Search" class="input input-sm w-full" /> </div> ``` - [x] In the Load button (line 164), wrap the "Load" text in `<span class="hidden md:inline">Load</span>` so only the icon shows on mobile - [x] In the Export button (line 307), wrap the "Export" text in `<span class="hidden md:inline">Export</span>` - [x] Theme toggle, About, Undo/Redo buttons are icon-only already — no changes needed ### Right panel changes (`src/index.html`) - [x] Add `id="right-panel"` to `.right-panel` div (currently has no ID; needed for JS to reference it) - [x] Hide `#panel-resizer` on mobile: add `hidden md:block` to its class list (line 401) - [x] Add a mobile drag handle as the **first child** of `.right-panel` (before `#panel-resizer`): ```html <div id="sheet-drag-handle" class="md:hidden flex justify-center items-center py-2 cursor-grab active:cursor-grabbing flex-shrink-0"> <div class="w-10 h-1 rounded-full bg-base-content/20"></div> </div> ``` ### Map controls changes (`src/index.html`) - [x] On `#map-controls` (line 333), replace `absolute top-4 right-4 flex-col items-end space-y-2` with responsive classes: `absolute top-2 right-2 md:top-4 md:right-4 flex flex-row md:flex-col items-center md:items-end gap-2 md:space-y-2` - [x] On the search card (line 338), add `hidden md:block` — search is hidden in the overlay on mobile (it moves to the navbar) - [x] On the tools card (line 348), remove `shadow-lg` on mobile if desired — or leave it (left as-is) ### CSS (`src/styles/main.css`) - [x] Add a mobile block after the existing `.app-container` rule: ```css @media (max-width: 767px) { .app-container { grid-template-columns: 1fr; } #right-panel { position: fixed; bottom: 0; left: 0; right: 0; height: var(--sheet-height, 56px); z-index: 50; border-left: none; border-top: 1px solid oklch(var(--b3)); border-radius: 1rem 1rem 0 0; transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; } /* Only allow content scroll in full-screen state */ #right-panel .tab-content { overflow-y: hidden; } #right-panel.sheet-full .tab-content { overflow-y: auto; } } ``` **Gotchas:** - The `transition: height` on `#right-panel` conflicts with drag (during drag we should suppress the transition and re-enable it on snap). `BottomSheetController` in Phase 2 removes the transition class during drag and adds it back on release. - The `.app-container` CSS variable rule from plan #35 (`grid-template-columns: 1fr var(--panel-width, 650px)`) is overridden cleanly by the media query above. - `oklch(var(--b3))` is the DaisyUI v5 way to reference theme colors in plain CSS. ## Phase 2: BottomSheetController + SearchController Update + Wiring Goal: Make the bottom sheet interactive — draggable with 3 snap heights — and wire up the mobile search input. ### Snap heights ``` peek: 56px — tab labels only visible (the "bottom dock") half: 45vh — partial content view, no scrolling full: 92vh — full content, scrolling enabled ``` ### Create `src/modules/bottom-sheet.ts` - [x] Implement `BottomSheetController` class: ```typescript import { TabManager } from './tab-manager'; type Snap = 'peek' | 'half' | 'full'; const PEEK_PX = 56; const HALF_VH = 0.45; const FULL_VH = 0.92; export class BottomSheetController { private snap: Snap = 'peek'; private panel: HTMLElement; constructor(panel: HTMLElement, tabManager: TabManager) { this.panel = panel; // Only activate on mobile if (window.innerWidth >= 768) return; this.setupDragHandle(); this.setupTabExpansion(tabManager); this.setSnap('peek', false); // Re-check on resize (e.g. orientation change) window.addEventListener('resize', () => { if (window.innerWidth >= 768) { panel.style.removeProperty('height'); panel.classList.remove('sheet-full'); } else { this.setSnap(this.snap, false); } }); } private setupDragHandle(): void { const handle = document.getElementById('sheet-drag-handle'); if (!handle) return; let startY = 0; let startHeight = 0; let lastY = 0; let lastTime = 0; let velocity = 0; const onStart = (clientY: number) => { startY = clientY; startHeight = this.panel.getBoundingClientRect().height; lastY = clientY; lastTime = Date.now(); velocity = 0; this.panel.style.transition = 'none'; }; const onMove = (clientY: number) => { const now = Date.now(); const dt = now - lastTime; if (dt > 0) velocity = (lastY - clientY) / dt; // px/ms, positive = up lastY = clientY; lastTime = now; const delta = startY - clientY; // positive = dragging up const newHeight = Math.min( window.innerHeight * FULL_VH, Math.max(PEEK_PX, startHeight + delta) ); this.panel.style.height = `${newHeight}px`; }; const onEnd = () => { this.panel.style.transition = ''; const targetSnap = this.resolveSnap(velocity); this.setSnap(targetSnap, true); }; // Touch handle.addEventListener('touchstart', (e) => onStart(e.touches[0].clientY), { passive: true }); document.addEventListener('touchmove', (e) => { if (startY) onMove(e.touches[0].clientY); }, { passive: true }); document.addEventListener('touchend', () => { if (startY) { onEnd(); startY = 0; } }); // Mouse (for desktop testing) handle.addEventListener('mousedown', (e) => { e.preventDefault(); onStart(e.clientY); }); document.addEventListener('mousemove', (e) => { if (startY) onMove(e.clientY); }); document.addEventListener('mouseup', () => { if (startY) { onEnd(); startY = 0; } }); } private resolveSnap(velocity: number): Snap { const VELOCITY_THRESHOLD = 0.4; // px/ms if (velocity > VELOCITY_THRESHOLD) { return this.snap === 'peek' ? 'half' : 'full'; } if (velocity < -VELOCITY_THRESHOLD) { return this.snap === 'full' ? 'half' : 'peek'; } // Snap to nearest based on current height const h = this.panel.getBoundingClientRect().height; const halfH = window.innerHeight * HALF_VH; const fullH = window.innerHeight * FULL_VH; if (h < (PEEK_PX + halfH) / 2) return 'peek'; if (h < (halfH + fullH) / 2) return 'half'; return 'full'; } private setSnap(snap: Snap, animate: boolean): void { this.snap = snap; if (!animate) this.panel.style.transition = 'none'; const h = snap === 'peek' ? PEEK_PX : snap === 'half' ? window.innerHeight * HALF_VH : window.innerHeight * FULL_VH; this.panel.style.setProperty('--sheet-height', `${h}px`); this.panel.style.height = `${h}px`; this.panel.classList.toggle('sheet-full', snap === 'full'); if (!animate) { // Re-enable transition after layout settles requestAnimationFrame(() => { this.panel.style.transition = ''; }); } } private setupTabExpansion(tabManager: TabManager): void { // When a tab is activated while the sheet is at peek, expand to half tabManager.onTabChange(() => { if (this.snap === 'peek') this.setSnap('half', true); }); } public expandTo(snap: 'half' | 'full'): void { this.setSnap(snap, true); } } ``` ### Update `src/modules/search-controller.ts` - [x] Find where `#map-search` is queried (around line 33–35). After initializing the desktop input, also wire `#mobile-search`: - Extract the event listener setup into a helper `private wireInput(input: HTMLInputElement): void` - Call it for both `document.getElementById('map-search')` and `document.getElementById('mobile-search')`, filtering out null - When a search result is selected, sync the value to both inputs: ```typescript private syncInputs(value: string): void { for (const input of this.inputs) input.value = value; } ``` ### Wire `BottomSheetController` in `src/index.ts` - [x] Import `BottomSheetController` from `./modules/bottom-sheet` - [x] In the `GTFSEditor` constructor, after `tabManager` is set up: ```typescript const rightPanel = document.getElementById('right-panel'); if (rightPanel) new BottomSheetController(rightPanel, this.tabManager); ``` No need to store as a class property. **Gotchas:** - `onTabChange` on `TabManager` needs to fire for programmatic tab switches (via `switchToTab`) not just user clicks. Check `tab-manager.ts:40–51` — if it only listens to DOM `change` events, `switchToTab` (which sets `checked` directly without dispatching an event) may not trigger it. If so, `switchToTab` should dispatch a synthetic `change` event or `TabManager` needs a callback registration path that `switchToTab` also calls. - During drag, `document.body.style.userSelect = 'none'` should be set to prevent accidental text selection. Restore on `touchend`/`mouseup`. - `window.innerWidth < 768` is checked at constructor time. If the user resizes from desktop to mobile, the controller won't activate. This is acceptable for a v1 — phone browsers don't typically resize. - The `PEEK_PX = 56` value must match the actual rendered height of the DaisyUI tab label row. Measure this after implementation and adjust if needed. If DaisyUI tabs render taller, adjust accordingly. --- ## Original Issue Lets do a few things that make it usable on mobile. I think we can get away with leaving most things the same, just moving the high level things around. Lets use Google Maps as a model: - Nav bar at the top - Logo (small top left corner) - search middle - buttons next - add stop and move stop button bar just underneath nav - map behind everything - Dock at the bottom allows for choosing browse/files/changes - If you click browse, the bottom sheet pops up with the "Home" view - If you click files, bottom sheet shows up with the files view - Same with changes - When you highlight an element, it shows up in the bottom sheet and you switch to browse (we should make sure we switch to browse with web as well) - Bottom sheet is closeable Remember to use DaisyUI. Additionally, if there are things we can change in web that improve the experience and reduce differences between the two, lets do it.
maxtkc self-assigned this 2026-04-07 17:32:08 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
gtfs.zone/coloring-book#67
No description provided.