experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: extract shared grid navigation into app/lib/grid-nav.js

Extract duplicated vim-style grid navigation, toolbar setup, and view
preferences code from groups/home.js and search/home.js into a shared
module. Both consumers now import from peek://app/lib/grid-nav.js.

Shared module exports:
- getCards, updateSelection, activateSelected, getGridColumns
- createKeydownHandler (with callbacks for consumer-specific behavior)
- setupToolbar (parameterized with viewPrefs, sortOptions, callbacks)
- createViewPrefs (localStorage key + defaults)

+268 -288
+209
app/lib/grid-nav.js
··· 1 + /** 2 + * Shared grid navigation, toolbar setup, and view preferences 3 + * for vim-style hjkl/arrow grid navigation across card views. 4 + */ 5 + 6 + // ── Grid Navigation ───────────────────────────────────────────────────── 7 + 8 + /** 9 + * Get all cards in the current view 10 + */ 11 + export const getCards = () => { 12 + return Array.from(document.querySelectorAll('.cards peek-card')); 13 + }; 14 + 15 + /** 16 + * Update visual selection on cards 17 + * @param {object} state - must have `selectedIndex` property 18 + */ 19 + export const updateSelection = (state) => { 20 + const cards = getCards(); 21 + cards.forEach((card, i) => { 22 + card.selected = (i === state.selectedIndex); 23 + }); 24 + 25 + const selected = cards[state.selectedIndex]; 26 + if (selected) { 27 + selected.focus(); 28 + selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 29 + } 30 + }; 31 + 32 + /** 33 + * Activate the currently selected card (trigger click) 34 + * @param {object} state - must have `selectedIndex` property 35 + */ 36 + export const activateSelected = (state) => { 37 + const cards = getCards(); 38 + const selected = cards[state.selectedIndex]; 39 + if (selected) { 40 + selected.click(); 41 + } 42 + }; 43 + 44 + /** 45 + * Get number of columns in the grid based on card positions 46 + */ 47 + export const getGridColumns = (cards) => { 48 + if (cards.length < 2) return 1; 49 + const firstTop = cards[0].getBoundingClientRect().top; 50 + for (let i = 1; i < cards.length; i++) { 51 + if (cards[i].getBoundingClientRect().top !== firstTop) { 52 + return i; 53 + } 54 + } 55 + return cards.length; 56 + }; 57 + 58 + /** 59 + * Create a keydown handler for vim-style grid navigation. 60 + * 61 + * @param {object} state - must have `selectedIndex` property 62 + * @param {object} [callbacks] - optional hooks 63 + * @param {function} [callbacks.onActivate] - called on Enter (default: activateSelected) 64 + * @param {function} [callbacks.onEscape] - called on Escape key 65 + * @param {function} [callbacks.shouldIgnore] - return true to skip handling entirely 66 + * @param {function} [callbacks.isInputFocused] - return true when an input has focus 67 + * (h/l and arrow-left/right will be skipped so cursor can move in the input; 68 + * only arrow-up/down/Enter are handled when input is focused) 69 + */ 70 + export const createKeydownHandler = (state, callbacks = {}) => { 71 + return (e) => { 72 + // Let the consumer bail out entirely (e.g. when a create-input is focused) 73 + if (callbacks.shouldIgnore && callbacks.shouldIgnore(e)) return; 74 + 75 + const inputFocused = callbacks.isInputFocused ? callbacks.isInputFocused() : false; 76 + 77 + // Don't intercept most keys when an input is focused 78 + if (inputFocused && !['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) { 79 + return; 80 + } 81 + 82 + const cards = getCards(); 83 + if (cards.length === 0) return; 84 + 85 + const cols = getGridColumns(cards); 86 + 87 + switch (e.key) { 88 + case 'j': 89 + case 'ArrowDown': 90 + e.preventDefault(); 91 + if (state.selectedIndex + cols < cards.length) { 92 + state.selectedIndex += cols; 93 + updateSelection(state); 94 + } 95 + break; 96 + case 'k': 97 + case 'ArrowUp': 98 + e.preventDefault(); 99 + if (state.selectedIndex - cols >= 0) { 100 + state.selectedIndex -= cols; 101 + updateSelection(state); 102 + } 103 + break; 104 + case 'h': 105 + case 'ArrowLeft': 106 + if (inputFocused) return; 107 + e.preventDefault(); 108 + if (state.selectedIndex > 0) { 109 + state.selectedIndex--; 110 + updateSelection(state); 111 + } 112 + break; 113 + case 'l': 114 + case 'ArrowRight': 115 + if (inputFocused) return; 116 + e.preventDefault(); 117 + if (state.selectedIndex < cards.length - 1) { 118 + state.selectedIndex++; 119 + updateSelection(state); 120 + } 121 + break; 122 + case 'Enter': 123 + e.preventDefault(); 124 + if (callbacks.onActivate) { 125 + callbacks.onActivate(); 126 + } else { 127 + activateSelected(state); 128 + } 129 + break; 130 + case 'Escape': 131 + if (callbacks.onEscape) { 132 + e.preventDefault(); 133 + callbacks.onEscape(); 134 + } 135 + break; 136 + } 137 + }; 138 + }; 139 + 140 + // ── Toolbar Setup ─────────────────────────────────────────────────────── 141 + 142 + /** 143 + * Wire up a grid-toolbar element with sort/view-mode change events. 144 + * 145 + * @param {object} options 146 + * @param {object} options.viewPrefs - viewPrefs object (mutated in place) 147 + * @param {Array} options.sortOptions - sort option descriptors 148 + * @param {function} options.onSave - called after prefs change to persist 149 + * @param {function} options.onRender - called after prefs change to re-render 150 + */ 151 + export const setupToolbar = ({ viewPrefs, sortOptions, onSave, onRender }) => { 152 + const toolbar = document.querySelector('.grid-toolbar'); 153 + const container = document.querySelector('.cards'); 154 + if (!toolbar) return; 155 + 156 + toolbar.sortOptions = sortOptions; 157 + toolbar.sortBy = viewPrefs.sortBy; 158 + toolbar.sortDirection = viewPrefs.sortDirection; 159 + toolbar.viewMode = viewPrefs.viewMode; 160 + container.viewMode = viewPrefs.viewMode; 161 + 162 + toolbar.addEventListener('sort-change', (e) => { 163 + viewPrefs.sortBy = e.detail.sortBy; 164 + viewPrefs.sortDirection = e.detail.sortDirection; 165 + onSave(); 166 + onRender(); 167 + }); 168 + 169 + toolbar.addEventListener('view-mode-change', (e) => { 170 + viewPrefs.viewMode = e.detail.mode; 171 + container.viewMode = e.detail.mode; 172 + onSave(); 173 + onRender(); 174 + }); 175 + }; 176 + 177 + // ── View Preferences ──────────────────────────────────────────────────── 178 + 179 + /** 180 + * Create a view-preferences helper bound to a localStorage key. 181 + * 182 + * @param {string} storageKey - localStorage key 183 + * @param {object} defaults - default prefs (viewMode, sortBy, sortDirection) 184 + * @returns {{ prefs: object, load: function, save: function }} 185 + */ 186 + export const createViewPrefs = (storageKey, defaults) => { 187 + const prefs = { ...defaults }; 188 + 189 + const load = () => { 190 + try { 191 + const stored = localStorage.getItem(storageKey); 192 + if (stored) { 193 + Object.assign(prefs, JSON.parse(stored)); 194 + } 195 + } catch (err) { 196 + console.log(`[grid-nav] Failed to load viewPrefs (${storageKey}):`, err); 197 + } 198 + }; 199 + 200 + const save = () => { 201 + try { 202 + localStorage.setItem(storageKey, JSON.stringify(prefs)); 203 + } catch (err) { 204 + console.log(`[grid-nav] Failed to save viewPrefs (${storageKey}):`, err); 205 + } 206 + }; 207 + 208 + return { prefs, load, save }; 209 + };
+41 -161
extensions/groups/home.js
··· 8 8 * - Viewing a group shows all addresses with that tag 9 9 */ 10 10 11 + import { 12 + updateSelection as _updateSelection, activateSelected as _activateSelected, 13 + createKeydownHandler, 14 + setupToolbar as _setupToolbar, 15 + createViewPrefs 16 + } from 'peek://app/lib/grid-nav.js'; 17 + 11 18 const api = window.app; 12 19 const debug = api.debug; 13 20 ··· 45 52 }; 46 53 47 54 // View preferences (persisted) 48 - let viewPrefs = { 55 + const { prefs: viewPrefs, load: loadViewPrefs, save: saveViewPrefs } = createViewPrefs('viewPrefs', { 49 56 viewMode: 'columns', 50 57 sortBy: 'name', 51 58 sortDirection: 'asc' 52 - }; 59 + }); 53 60 54 61 const SORT_OPTIONS_GROUPS = [ 55 62 { value: 'name', label: 'Name' }, ··· 162 169 }; 163 170 164 171 /** 165 - * Get all cards in the current view 166 - */ 167 - const getCards = () => { 168 - return Array.from(document.querySelectorAll('.cards peek-card')); 169 - }; 170 - 171 - /** 172 - * Update visual selection on cards 173 - */ 174 - const updateSelection = () => { 175 - const cards = getCards(); 176 - cards.forEach((card, i) => { 177 - card.selected = (i === state.selectedIndex); 178 - }); 179 - 180 - // Focus and scroll selected card into view 181 - const selected = cards[state.selectedIndex]; 182 - if (selected) { 183 - selected.focus(); 184 - selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 185 - } 186 - }; 187 - 188 - /** 189 - * Activate the currently selected card 172 + * Wrappers around shared grid-nav functions, bound to local state 190 173 */ 191 - const activateSelected = () => { 192 - const cards = getCards(); 193 - const selected = cards[state.selectedIndex]; 194 - if (selected) { 195 - selected.click(); 196 - } 197 - }; 198 - 199 - /** 200 - * Get number of columns in the grid based on card positions 201 - */ 202 - const getGridColumns = (cards) => { 203 - if (cards.length < 2) return 1; 204 - const firstTop = cards[0].getBoundingClientRect().top; 205 - for (let i = 1; i < cards.length; i++) { 206 - if (cards[i].getBoundingClientRect().top !== firstTop) { 207 - return i; 208 - } 209 - } 210 - return cards.length; // All on one row 211 - }; 174 + const updateSelection = () => _updateSelection(state); 175 + const activateSelected = () => _activateSelected(state); 212 176 213 177 /** 214 178 * Handle keyboard navigation (vim-style hjkl for grid movement) 179 + * Uses shared grid-nav with groups-specific hooks for search focus and new-group input bypass 215 180 */ 216 - const handleKeydown = (e) => { 217 - const searchInput = document.querySelector('peek-input.search-input'); 218 - // Check if search input or its internal input is focused 219 - const isSearchFocused = document.activeElement === searchInput || 220 - (searchInput && searchInput.shadowRoot?.activeElement); 221 - 222 - // Check if the create-group input is focused — if so, let all keys through 223 - const newGroupInput = document.querySelector('peek-input.new-group-input'); 224 - const isNewGroupFocused = document.activeElement === newGroupInput || 225 - (newGroupInput && newGroupInput.shadowRoot?.activeElement); 226 - if (isNewGroupFocused) return; 227 - 228 - // Focus search with / or Cmd+F 229 - if ((e.key === '/' || (e.key === 'f' && (e.metaKey || e.ctrlKey))) && !isSearchFocused) { 230 - e.preventDefault(); 231 - searchInput.focus(); 232 - return; 233 - } 181 + const handleKeydown = createKeydownHandler(state, { 182 + shouldIgnore: (e) => { 183 + // Check if the create-group input is focused — if so, let all keys through 184 + const newGroupInput = document.querySelector('peek-input.new-group-input'); 185 + const isNewGroupFocused = document.activeElement === newGroupInput || 186 + (newGroupInput && newGroupInput.shadowRoot?.activeElement); 187 + if (isNewGroupFocused) return true; 234 188 235 - // Don't intercept when typing in search (except arrow keys and enter) 236 - if (isSearchFocused && !['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) { 237 - return; 238 - } 189 + const searchInput = document.querySelector('peek-input.search-input'); 190 + const isSearchFocused = document.activeElement === searchInput || 191 + (searchInput && searchInput.shadowRoot?.activeElement); 239 192 240 - const cards = getCards(); 241 - if (cards.length === 0) return; 242 - 243 - const cols = getGridColumns(cards); 244 - 245 - switch (e.key) { 246 - case 'j': 247 - case 'ArrowDown': 248 - e.preventDefault(); 249 - if (state.selectedIndex + cols < cards.length) { 250 - state.selectedIndex += cols; 251 - updateSelection(); 252 - } 253 - break; 254 - case 'k': 255 - case 'ArrowUp': 256 - e.preventDefault(); 257 - if (state.selectedIndex - cols >= 0) { 258 - state.selectedIndex -= cols; 259 - updateSelection(); 260 - } 261 - break; 262 - case 'h': 263 - case 'ArrowLeft': 264 - if (isSearchFocused) return; // Let cursor move in search 265 - e.preventDefault(); 266 - if (state.selectedIndex > 0) { 267 - state.selectedIndex--; 268 - updateSelection(); 269 - } 270 - break; 271 - case 'l': 272 - case 'ArrowRight': 273 - if (isSearchFocused) return; // Let cursor move in search 193 + // Focus search with / or Cmd+F 194 + if ((e.key === '/' || (e.key === 'f' && (e.metaKey || e.ctrlKey))) && !isSearchFocused) { 274 195 e.preventDefault(); 275 - if (state.selectedIndex < cards.length - 1) { 276 - state.selectedIndex++; 277 - updateSelection(); 278 - } 279 - break; 280 - case 'Enter': 281 - e.preventDefault(); 282 - activateSelected(); 283 - break; 284 - } 285 - }; 196 + searchInput.focus(); 197 + return true; 198 + } 286 199 287 - /** 288 - * Load persisted view preferences 289 - */ 290 - const loadViewPrefs = async () => { 291 - try { 292 - const stored = localStorage.getItem('viewPrefs'); 293 - if (stored) { 294 - viewPrefs = { ...viewPrefs, ...JSON.parse(stored) }; 295 - debug && console.log('[groups] Loaded viewPrefs:', viewPrefs); 296 - } 297 - } catch (err) { 298 - debug && console.log('[groups] Failed to load viewPrefs:', err); 200 + return false; 201 + }, 202 + isInputFocused: () => { 203 + const searchInput = document.querySelector('peek-input.search-input'); 204 + return document.activeElement === searchInput || 205 + !!(searchInput && searchInput.shadowRoot?.activeElement); 299 206 } 300 - }; 207 + }); 301 208 302 - /** 303 - * Save view preferences 304 - */ 305 - const saveViewPrefs = async () => { 306 - try { 307 - localStorage.setItem('viewPrefs', JSON.stringify(viewPrefs)); 308 - } catch (err) { 309 - debug && console.log('[groups] Failed to save viewPrefs:', err); 310 - } 311 - }; 312 209 313 210 /** 314 211 * Set up grid toolbar events and state 315 212 */ 316 213 const setupToolbar = () => { 317 - const toolbar = document.querySelector('.grid-toolbar'); 318 - const container = document.querySelector('.cards'); 319 - if (!toolbar) return; 320 - 321 - toolbar.sortOptions = SORT_OPTIONS_GROUPS; 322 - toolbar.sortBy = viewPrefs.sortBy; 323 - toolbar.sortDirection = viewPrefs.sortDirection; 324 - toolbar.viewMode = viewPrefs.viewMode; 325 - container.viewMode = viewPrefs.viewMode; 326 - 327 - toolbar.addEventListener('sort-change', (e) => { 328 - viewPrefs.sortBy = e.detail.sortBy; 329 - viewPrefs.sortDirection = e.detail.sortDirection; 330 - saveViewPrefs(); 331 - renderCurrentView(); 332 - }); 333 - 334 - toolbar.addEventListener('view-mode-change', (e) => { 335 - viewPrefs.viewMode = e.detail.mode; 336 - container.viewMode = e.detail.mode; 337 - saveViewPrefs(); 338 - renderCurrentView(); 214 + _setupToolbar({ 215 + viewPrefs, 216 + sortOptions: SORT_OPTIONS_GROUPS, 217 + onSave: saveViewPrefs, 218 + onRender: renderCurrentView 339 219 }); 340 220 }; 341 221 ··· 469 349 api.escape.onEscape(handleEscape); 470 350 471 351 // Load view preferences and set up toolbar 472 - await loadViewPrefs(); 352 + loadViewPrefs(); 473 353 setupToolbar(); 474 354 475 355 // Load tags from datastore
+18 -127
extensions/search/home.js
··· 7 7 * #a #b foo — items tagged "a" AND "b" with text matching "foo" 8 8 */ 9 9 10 + import { 11 + updateSelection as _updateSelection, 12 + createKeydownHandler, 13 + setupToolbar as _setupToolbar, 14 + createViewPrefs 15 + } from 'peek://app/lib/grid-nav.js'; 16 + 10 17 const api = window.app; 11 18 const debug = api.debug; 12 19 ··· 21 28 }; 22 29 23 30 // View preferences 24 - let viewPrefs = { 31 + const { prefs: viewPrefs, load: loadViewPrefs, save: saveViewPrefs } = createViewPrefs('search:viewPrefs', { 25 32 viewMode: 'list', 26 33 sortBy: 'recent', 27 34 sortDirection: 'desc' 28 - }; 35 + }); 29 36 30 37 const SORT_OPTIONS = [ 31 38 { value: 'name', label: 'Name' }, ··· 364 371 365 372 // ── Keyboard navigation ──────────────────────────────────────────────── 366 373 367 - const getCards = () => { 368 - return Array.from(document.querySelectorAll('.cards peek-card')); 369 - }; 370 - 371 - const updateSelection = () => { 372 - const cards = getCards(); 373 - cards.forEach((card, i) => { 374 - card.selected = (i === state.selectedIndex); 375 - }); 376 - 377 - const selected = cards[state.selectedIndex]; 378 - if (selected) { 379 - selected.focus(); 380 - selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 381 - } 382 - }; 383 - 384 - const activateSelected = () => { 385 - const cards = getCards(); 386 - const selected = cards[state.selectedIndex]; 387 - if (selected) { 388 - selected.click(); 389 - } 390 - }; 374 + const updateSelection = () => _updateSelection(state); 391 375 392 - const getGridColumns = (cards) => { 393 - if (cards.length < 2) return 1; 394 - const firstTop = cards[0].getBoundingClientRect().top; 395 - for (let i = 1; i < cards.length; i++) { 396 - if (cards[i].getBoundingClientRect().top !== firstTop) { 397 - return i; 398 - } 399 - } 400 - return cards.length; 401 - }; 402 - 403 - const handleKeydown = (e) => { 404 - const cards = getCards(); 405 - if (cards.length === 0) return; 406 - 407 - const cols = getGridColumns(cards); 408 - 409 - switch (e.key) { 410 - case 'j': 411 - case 'ArrowDown': 412 - e.preventDefault(); 413 - if (state.selectedIndex + cols < cards.length) { 414 - state.selectedIndex += cols; 415 - updateSelection(); 416 - } 417 - break; 418 - case 'k': 419 - case 'ArrowUp': 420 - e.preventDefault(); 421 - if (state.selectedIndex - cols >= 0) { 422 - state.selectedIndex -= cols; 423 - updateSelection(); 424 - } 425 - break; 426 - case 'h': 427 - case 'ArrowLeft': 428 - e.preventDefault(); 429 - if (state.selectedIndex > 0) { 430 - state.selectedIndex--; 431 - updateSelection(); 432 - } 433 - break; 434 - case 'l': 435 - case 'ArrowRight': 436 - e.preventDefault(); 437 - if (state.selectedIndex < cards.length - 1) { 438 - state.selectedIndex++; 439 - updateSelection(); 440 - } 441 - break; 442 - case 'Enter': 443 - e.preventDefault(); 444 - activateSelected(); 445 - break; 446 - case 'Escape': 447 - e.preventDefault(); 448 - window.close(); 449 - break; 450 - } 451 - }; 376 + const handleKeydown = createKeydownHandler(state, { 377 + onEscape: () => window.close() 378 + }); 452 379 453 380 // ── Toolbar ──────────────────────────────────────────────────────────── 454 381 455 - const loadViewPrefs = () => { 456 - try { 457 - const stored = localStorage.getItem('search:viewPrefs'); 458 - if (stored) { 459 - viewPrefs = { ...viewPrefs, ...JSON.parse(stored) }; 460 - } 461 - } catch (err) { 462 - debug && console.log('[search] Failed to load viewPrefs:', err); 463 - } 464 - }; 465 - 466 - const saveViewPrefs = () => { 467 - try { 468 - localStorage.setItem('search:viewPrefs', JSON.stringify(viewPrefs)); 469 - } catch (err) { 470 - debug && console.log('[search] Failed to save viewPrefs:', err); 471 - } 472 - }; 473 - 474 382 const setupToolbar = () => { 475 - const toolbar = document.querySelector('.grid-toolbar'); 476 - const container = document.querySelector('.cards'); 477 - if (!toolbar) return; 478 - 479 - toolbar.sortOptions = SORT_OPTIONS; 480 - toolbar.sortBy = viewPrefs.sortBy; 481 - toolbar.sortDirection = viewPrefs.sortDirection; 482 - toolbar.viewMode = viewPrefs.viewMode; 483 - container.viewMode = viewPrefs.viewMode; 484 - 485 - toolbar.addEventListener('sort-change', (e) => { 486 - viewPrefs.sortBy = e.detail.sortBy; 487 - viewPrefs.sortDirection = e.detail.sortDirection; 488 - saveViewPrefs(); 489 - render(); 490 - }); 491 - 492 - toolbar.addEventListener('view-mode-change', (e) => { 493 - viewPrefs.viewMode = e.detail.mode; 494 - container.viewMode = e.detail.mode; 495 - saveViewPrefs(); 496 - render(); 383 + _setupToolbar({ 384 + viewPrefs, 385 + sortOptions: SORT_OPTIONS, 386 + onSave: saveViewPrefs, 387 + onRender: render 497 388 }); 498 389 }; 499 390