An app for logging board climbs
0
fork

Configure Feed

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

feat: create ui-counter component

+152 -78
+90
www/components/ui-counter.ts
··· 1 + class UICounter extends HTMLElement { 2 + static observedAttributes = ['value'] 3 + 4 + connectedCallback() { 5 + this.#render() 6 + this.addEventListener('click', this.#handleClick) 7 + this.addEventListener('change', this.#handleInputChange) 8 + } 9 + 10 + disconnectedCallback() { 11 + this.removeEventListener('click', this.#handleClick) 12 + this.removeEventListener('change', this.#handleInputChange) 13 + } 14 + 15 + get value(): number { 16 + return parseInt(this.getAttribute('value') ?? '0') 17 + } 18 + 19 + set value(v: number) { 20 + this.#updateValue(v) 21 + } 22 + 23 + get #min(): number { 24 + const v = this.getAttribute('min') 25 + return v !== null ? parseInt(v) : -Infinity 26 + } 27 + 28 + get #max(): number { 29 + const v = this.getAttribute('max') 30 + return v !== null ? parseInt(v) : Infinity 31 + } 32 + 33 + get #step(): number { 34 + return parseInt(this.getAttribute('step') ?? '1') 35 + } 36 + 37 + #handleClick = (e: Event) => { 38 + const target = e.target as HTMLElement 39 + if (target.closest('[data-action="decrement"]')) { 40 + this.#updateValue(this.value - this.#step) 41 + } else if (target.closest('[data-action="increment"]')) { 42 + this.#updateValue(this.value + this.#step) 43 + } 44 + } 45 + 46 + #handleInputChange = (e: Event) => { 47 + const target = e.target as HTMLInputElement 48 + if (target.tagName !== 'INPUT') return 49 + e.stopPropagation() 50 + const parsed = parseInt(target.value) 51 + this.#updateValue(isNaN(parsed) ? this.value : parsed) 52 + } 53 + 54 + #updateValue(raw: number) { 55 + const clamped = Math.max( 56 + isFinite(this.#min) ? this.#min : -Infinity, 57 + Math.min(isFinite(this.#max) ? this.#max : Infinity, raw), 58 + ) 59 + this.setAttribute('value', String(clamped)) 60 + const input = this.querySelector<HTMLInputElement>('input') 61 + if (input && input.value !== String(clamped)) input.value = String(clamped) 62 + this.dispatchEvent(new Event('change', { bubbles: true })) 63 + } 64 + 65 + attributeChangedCallback(_: string, _old: string, next: string) { 66 + if (!this.isConnected) return 67 + const input = this.querySelector<HTMLInputElement>('input') 68 + if (input && input.value !== next) input.value = next 69 + } 70 + 71 + #render() { 72 + const min = this.getAttribute('min') 73 + const max = this.getAttribute('max') 74 + const step = this.getAttribute('step') ?? '1' 75 + const value = this.getAttribute('value') ?? '0' 76 + this.innerHTML = ` 77 + <button type="button" data-action="decrement" aria-label="Decrease">−</button> 78 + <input 79 + type="number" 80 + value="${value}" 81 + ${min !== null ? `min="${min}"` : ''} 82 + ${max !== null ? `max="${max}"` : ''} 83 + step="${step}" 84 + > 85 + <button type="button" data-action="increment" aria-label="Increase">+</button> 86 + ` 87 + } 88 + } 89 + 90 + customElements.define('ui-counter', UICounter)
+23 -33
www/routes/climb.ts
··· 12 12 import { getClimbNav, setClimbNav } from '../utils/climb-nav.ts' 13 13 import app from '../models/app.ts' 14 14 import { activeClimbHeader } from '../components/climb-header.ts' 15 + import '../components/ui-counter.ts' 15 16 16 17 export class ClimbPage extends HTMLElement { 17 18 private hammer: HammerManager | null = null ··· 43 44 44 45 this.innerHTML = ` 45 46 <div id="cp-body"></div> 46 - <div id="cp-dialog" class="lb-overlay" hidden> 47 - <div class="lb-dialog" id="lb-dialog-box"></div> 48 - </div> 47 + <dialog id="cp-dialog"> 48 + <div id="lb-dialog-box" class="lb-dialog"></div> 49 + </dialog> 49 50 ` 50 51 51 52 // Dialog events — bound once for the component lifetime 52 - this.querySelector('#cp-dialog')?.addEventListener('click', (e) => { 53 - if (e.target === this.querySelector('#cp-dialog')) this.hideLogDialog() 53 + const dialog = this.querySelector<HTMLDialogElement>('#cp-dialog')! 54 + dialog.addEventListener('click', (e) => { 55 + if (e.target === dialog) this.hideLogDialog() 56 + }) 57 + dialog.addEventListener('cancel', (e) => { 58 + e.preventDefault() 59 + this.hideLogDialog() 54 60 }) 55 61 this.querySelector('#lb-dialog-box')?.addEventListener( 56 62 'click', ··· 169 175 private showLogDialog(climb: Benchmark): void { 170 176 this.currentDialogClimb = climb 171 177 this.dialogRating = null 178 + const dialog = this.querySelector<HTMLDialogElement>('#cp-dialog') 172 179 const box = this.querySelector<HTMLElement>('#lb-dialog-box') 173 - const overlay = this.querySelector<HTMLElement>('#cp-dialog') 174 - if (!box || !overlay) return 180 + if (!dialog || !box) return 175 181 176 182 box.innerHTML = ` 177 183 <div class="lb-dialog-header"> ··· 179 185 <button class="lb-dialog-close" id="lb-dialog-close" aria-label="Close">✕</button> 180 186 </div> 181 187 <div class="lb-field"> 182 - <label class="lb-field-label" for="lb-attempts">Attempts</label> 183 - <div class="lb-counter"> 184 - <button class="lb-counter-btn" id="lb-dec" type="button">−</button> 185 - <input type="number" id="lb-attempts" class="lb-counter-input" value="1" min="1" max="999"> 186 - <button class="lb-counter-btn" id="lb-inc" type="button">+</button> 187 - </div> 188 + <label class="lb-field-label">Attempts</label> 189 + <ui-counter id="lb-attempts" value="1" min="1" max="999"></ui-counter> 188 190 </div> 189 191 <div class="lb-field lb-field--row"> 190 192 <label class="lb-field-label" for="lb-sent">Sent?</label> ··· 202 204 </div> 203 205 <button class="lb-submit-btn" id="lb-submit" type="button">Save Session</button> 204 206 ` 205 - overlay.hidden = false 207 + dialog.showModal() 206 208 } 207 209 208 210 private hideLogDialog(): void { 209 - const overlay = this.querySelector<HTMLElement>('#cp-dialog') 210 - if (overlay) overlay.hidden = true 211 + const dialog = this.querySelector<HTMLDialogElement>('#cp-dialog') 212 + dialog?.close() 211 213 this.currentDialogClimb = null 212 214 this.dialogRating = null 213 215 } ··· 222 224 return 223 225 } 224 226 225 - const attemptsInput = box.querySelector<HTMLInputElement>('#lb-attempts') 226 - 227 - if (target.closest('#lb-dec') && attemptsInput) { 228 - attemptsInput.value = String( 229 - Math.max(1, parseInt(attemptsInput.value) - 1), 230 - ) 231 - return 232 - } 233 - 234 - if (target.closest('#lb-inc') && attemptsInput) { 235 - attemptsInput.value = String( 236 - Math.min(999, parseInt(attemptsInput.value) + 1), 237 - ) 238 - return 239 - } 240 - 241 227 const star = target.closest<HTMLElement>('.lb-star-btn') 242 228 if (star) { 243 229 const val = parseInt(star.dataset.star ?? '0') ··· 254 240 255 241 if (target.closest('#lb-submit')) { 256 242 if (!this.currentDialogClimb) return 257 - const attempts = Math.max(1, parseInt(attemptsInput?.value ?? '1')) 243 + const counterEl = box.querySelector('#lb-attempts') 244 + const attempts = Math.max( 245 + 1, 246 + parseInt(counterEl?.getAttribute('value') ?? '1'), 247 + ) 258 248 const sent = box.querySelector<HTMLInputElement>('#lb-sent')?.checked ?? 259 249 false 260 250 const climb = this.currentDialogClimb
+27
www/static/civility.css
··· 1889 1889 ui-version button { 1890 1890 margin-top: var(--s1); 1891 1891 } 1892 + 1893 + /* UI Counter */ 1894 + ui-counter { 1895 + display: flex; 1896 + align-items: stretch; 1897 + gap: var(--s2); 1898 + } 1899 + 1900 + ui-counter button { 1901 + display: flex; 1902 + align-items: center; 1903 + justify-content: center; 1904 + flex-shrink: 0; 1905 + } 1906 + 1907 + ui-counter input[type='number'] { 1908 + flex: 1; 1909 + text-align: center; 1910 + padding: 0; 1911 + margin: 0; 1912 + -moz-appearance: textfield; 1913 + } 1914 + 1915 + ui-counter input[type='number']::-webkit-inner-spin-button, 1916 + ui-counter input[type='number']::-webkit-outer-spin-button { 1917 + -webkit-appearance: none; 1918 + } 1892 1919 } 1893 1920 1894 1921 @layer utilities {
+12 -45
www/static/theme.css
··· 709 709 710 710 /* ── Logbook session dialog ─────────────────────────────────────────────── */ 711 711 712 - .lb-overlay { 713 - position: fixed; 714 - inset: 0; 715 - background: rgba(0, 0, 0, 0.5); 716 - display: flex; 717 - align-items: center; 718 - justify-content: center; 719 - z-index: calc(var(--z-fixed) + 10); 720 - padding: var(--s3); 712 + #cp-dialog { 713 + border: none; 714 + padding: 0; 715 + background: transparent; 716 + max-width: calc(100vw - 2 * var(--s3)); 721 717 } 722 718 723 - .lb-overlay[hidden] { 724 - display: none; 719 + #cp-dialog::backdrop { 720 + background: rgba(0, 0, 0, 0.5); 725 721 } 726 722 727 723 .lb-dialog { ··· 780 776 opacity: 0.7; 781 777 } 782 778 783 - .lb-counter { 784 - display: flex; 785 - align-items: stretch; 786 - gap: var(--s2); 779 + #lb-dialog-box ui-counter { 787 780 height: 40px; 788 781 } 789 782 790 - .lb-counter-btn { 791 - display: flex; 792 - align-items: center; 793 - justify-content: center; 783 + #lb-dialog-box ui-counter button { 794 784 width: 40px; 795 785 height: 40px; 796 - border: 1px solid currentColor; 797 - border-radius: var(--br-base); 798 - background: transparent; 799 - color: inherit; 800 786 font-size: var(--f2); 801 - cursor: pointer; 802 - flex-shrink: 0; 787 + border-radius: var(--br-base); 803 788 } 804 789 805 - .lb-counter-btn:hover { 806 - opacity: 0.7; 807 - transform: none; 808 - } 809 - 810 - .lb-counter-input { 811 - flex: 1; 812 - text-align: center; 813 - border: 1px solid currentColor; 814 - border-radius: var(--br-base); 815 - background: transparent; 816 - color: inherit; 790 + #lb-dialog-box ui-counter input { 817 791 font-size: var(--f3); 818 792 font-weight: var(--fw-semibold); 819 - padding: 0; 820 - margin: 0; 821 793 font-variant-numeric: tabular-nums; 822 - -moz-appearance: textfield; 823 - } 824 - 825 - .lb-counter-input::-webkit-inner-spin-button, 826 - .lb-counter-input::-webkit-outer-spin-button { 827 - -webkit-appearance: none; 794 + border-radius: var(--br-base); 828 795 } 829 796 830 797 .lb-checkbox {