components
All repositories: gitoria
10.4 KB
// range-picker.hl — THE DATE RANGE PICKER, all Hybriel: one text field ("YYYY-MM-DD – YYYY-MM-DD", typing works) that opens a// month under it; a click on a day sets the start, a second click sets the end (an earlier second click swaps them), the// days between are highlighted. The month grid is built here (no Date arithmetic: plain integers, so the server render and// the browser agree); the browser's own today and language come in when the field gets focus (`Date` and `Intl` are// browser globals of View handlers). The popup is shown by CSS while the field or the popup has focus (:focus-within), so// no `if` re-creates the DOM. The field posts in a form by its `name`.import { now, timestamp } from 'hl:time'// the server's today (UTC) for the first render only; the browser corrects it when the field gets focusserverToday = () => { return timestamp(now()).slice(0, 10) }String name = 'range'String value = ''String kept = ''String pending = ''String start = ''String end = ''String invalidText = 'false'String today = serverToday()Number year = toNumber(today.slice(0, 4))Number month = toNumber(today.slice(5, 7))Hybrid monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']Hybrid weekdayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']Hybrid weekdays = weekdayNamesString title = monthNames[month - 1] + ' ' + yearHybrid days = build(year, month, today, '', '')String prevLabel = 'Previous month'String nextLabel = 'Next month'String placeholder = 'YYYY-MM-DD – YYYY-MM-DD'String sep = ' – 'pad(n) { return n < 10 ? '0' + n : '' + n }leap(y) { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 }daysIn(y, m) {if (m == 2) { return leap(y) ? 29 : 28 }if (m == 4 || m == 6 || m == 9 || m == 11) { return 30 }return 31}// 0 = Monday … 6 = Sunday (Sakamoto's method gives 0 = Sunday)weekdayOf(y, m, d) {t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]yy = m < 3 ? y - 1 : ysun = (yy + (yy - yy % 4) / 4 - (yy - yy % 100) / 100 + (yy - yy % 400) / 400 + t[m - 1] + d) % 7return (sun + 6) % 7}// "YYYY-MM-DD" → yyyymmdd as a number (strings have no ordering operators), or 0 when it is not a real daykeyOf(s) {if (s.length != 10 || s.slice(4, 5) != '-' || s.slice(7, 8) != '-') { return 0 }y = toNumber(s.slice(0, 4))m = toNumber(s.slice(5, 7))d = toNumber(s.slice(8, 10))if (y == null || m == null || d == null) { return 0 }if (m < 1 || m > 12 || d < 1 || d > daysIn(y, m)) { return 0 }if (('' + (10000 + y)).slice(1) + '-' + pad(m) + '-' + pad(d) != s) { return 0 }return y * 10000 + m * 100 + d}// 42 days (six weeks of seven) so the popup keeps its height; each carries its state as a classbuild(y, m, todayText, a, b) {py = m == 1 ? y - 1 : ypm = m == 1 ? 12 : m - 1ny = m == 12 ? y + 1 : ynm = m == 12 ? 1 : m + 1lead = weekdayOf(y, m, 1)let n = daysIn(y, m)pn = daysIn(py, pm)ka = keyOf(a)kb = keyOf(b)out = []let day = 1 - leadlet i = 0while (i < 42) {let cy = ylet cm = mlet cd = daylet cls = ''if (day < 1) { cy = py cm = pm cd = pn + day cls = 'outside' }if (day > n) { cy = ny cm = nm cd = day - n cls = 'outside' }let date = ('' + (10000 + cy)).slice(1) + '-' + pad(cm) + '-' + pad(cd)let k = cy * 10000 + cm * 100 + cdif (date == todayText) { cls = cls + ' today' }if (ka != 0 && kb != 0 && k > ka && k < kb) { cls = cls + ' between' }if ((ka != 0 && k == ka) || (kb != 0 && k == kb)) { cls = cls + ' selected' }out.push({ n = cd date = date cls = cls })day = day + 1i = i + 1}return out}show(y, m) {year = ymonth = mtitle = monthNames[m - 1] + ' ' + ydays = build(y, m, today, start, end)}// the field's text → sets start / end when it is two real days (either order), else marks the fieldapply(text) {n = text.lengtha = n >= 21 ? text.slice(0, 10) : ''b = n >= 21 ? text.slice(n - 10, n) : ''ka = keyOf(a)kb = keyOf(b)if (ka != 0 && kb != 0) {start = ka <= kb ? a : bend = ka <= kb ? b : ainvalidText = 'false'return true}start = ''end = ''invalidText = text != '' ? 'true' : 'false'return false}// the month of a "YYYY-MM-DD" day, when it is not the month on showgotoDay(s) {if (keyOf(s) == 0) { return null }show(toNumber(s.slice(0, 4)), toNumber(s.slice(5, 7)))}// Look: black, white and the greys #555 #999 #ddd #f2f2f2 (the library's base, named in shared/base.hl; README "Look"). Every colour is a semantic token// with its base value as fallback, `var(--color-…, #000)`, so a bare copy looks right and an app that declares the tokens (layouts.worldapi.org's// theme) restyles it. No frames: a border or ring only where it means something (focus, today, an invalid field). A component's browser module// cannot import tokens itself.Style {'range-picker' { position = 'relative' display = 'block' maxWidth = '18rem' }'range-picker input' { width = '100%' boxSizing = 'border-box' font = 'inherit' padding = '.4rem .6rem' color = 'var(--color-text, #000)' background = 'var(--color-background-sunken, #f2f2f2)' border = '0' borderRadius = '.4rem' }'range-picker input:focus' { outline = '2px solid var(--color-accent, #555)' outlineOffset = '-1px' }'range-picker input[aria-invalid="true"]' { outline = '2px dashed var(--color-danger, #000)' outlineOffset = '-2px' }'range-picker picker-popup' { display = 'none' position = 'absolute' top = '100%' left = '0' zIndex = '4' width = 'min(22rem, 90vw)' marginTop = '.25rem' outline = 'none' }'range-picker:focus-within picker-popup' { display = 'block' }'range-picker month-view' { display = 'grid' gap = '.5rem' padding = '.5rem' background = 'var(--color-background, #fff)' border = '0' borderRadius = '.5rem' boxShadow = '0 .5rem 1.5rem rgba(0, 0, 0, .25)' }'range-picker month-view header' { display = 'flex' alignItems = 'center' justifyContent = 'space-between' gap = '.5rem' }'range-picker month-view h2' { margin = '0' fontSize = '1.1rem' color = 'var(--color-text-strong, #000)' textAlign = 'center' flex = '1' textTransform = 'capitalize' }'range-picker month-view ol' { display = 'grid' gridTemplateColumns = 'repeat(7, minmax(0, 1fr))' gap = '.2rem' margin = '0' padding = '0' listStyle = 'none' }'range-picker button' { font = 'inherit' fontWeight = '600' padding = '.4rem .9rem' color = 'var(--color-text, #000)' background = 'transparent' border = '0' borderRadius = '.4rem' cursor = 'pointer' }'range-picker button:hover' { background = 'var(--color-surface, #f2f2f2)' }'range-picker ol.weekdays li' { padding = '.3rem 0' fontSize = '.8rem' fontWeight = '600' color = 'var(--color-text-muted, #555)' textAlign = 'center' }'range-picker ol.days li' { minHeight = '2rem' padding = '.3rem' border = '0' borderRadius = '.3rem' textAlign = 'right' background = 'var(--color-surface, #f2f2f2)' cursor = 'pointer' }'range-picker ol.days li:hover' { background = 'var(--color-background-sunken, #ddd)' }'range-picker ol.days time' { color = 'var(--color-text, #000)' fontSize = '.95rem' }'range-picker ol.days li.outside' { background = 'var(--color-background, #fff)' }'range-picker ol.days li.outside time' { color = 'var(--color-text-muted, #555)' }'range-picker ol.days li.today' { outline = '2px solid var(--color-accent, #000)' outlineOffset = '-2px' }'range-picker ol.days li.between' { background = 'var(--color-background-sunken, #ddd)' }'range-picker ol.days li.selected' { background = 'var(--color-accent, #000)' }'range-picker ol.days li.selected time' { color = 'var(--color-accent-text, #fff)' }}View {rangePicker {input { id = "range-picker-input" name = name type = "text" autocomplete = "off" maxlength = "23" title = placeholder placeholder = placeholder value = value 'aria-invalid' = invalidText on focus(e) { emit rangeOpen(e) } on input(e) { emit rangeTyped(e.target.value) } on keydown(e) { emit rangeKey(e.key) } }pickerPopup { tabindex = "-1"monthView {header {button { id = "range-prev" type = "button" class = "quiet" title = prevLabel "‹" on click(e) { emit rangePrev(e) } }h2 { title }button { id = "range-next" type = "button" class = "quiet" title = nextLabel "›" on click(e) { emit rangeNext(e) } }}ol { class = "weekdays" for (wd of weekdays) { li { wd } } }ol { class = "days" for (d of days) { li { class = d.cls time { datetime = d.date d.n } on click(e) { emit rangePick(d.date) } } } }}}}}// the browser knows today and its language; the field gets focus first, so this runs before a day is clickedon rangeOpen(e) {let now = new Date()lang = navigator.language != null ? navigator.language : 'en't = ('' + (10000 + now.getFullYear())).slice(1) + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())if (t != today) {mf = Intl.DateTimeFormat(lang, { month = 'long' })df = Intl.DateTimeFormat(lang, { weekday = 'short' })ms = []ds = []let i = 0while (i < 12) { ms.push(mf.format(new Date(Date.UTC(2024, i, 15, 12)))) i = i + 1 }i = 0while (i < 7) { ds.push(df.format(new Date(Date.UTC(2024, 0, 1 + i, 12)))) i = i + 1 }today = tmonthNames = msweekdayNames = dsweekdays = dsde = lang.slice(0, 2) == 'de'prevLabel = de ? 'Voriger Monat' : 'Previous month'nextLabel = de ? 'Nächster Monat' : 'Next month'}// a half-picked start is dropped; the month shown is the range's start, else today's monthif (pending != '') {pending = ''value = keptapply(kept)}if (start != '') { gotoDay(start) } else { show(toNumber(today.slice(0, 4)), toNumber(today.slice(5, 7))) }}on rangeTyped(text) {pending = ''value = textif (apply(text)) { gotoDay(start) } else { show(year, month) }kept = start != '' ? start + sep + end : ''}on rangeKey(key) {if (key == 'Escape' || key == 'Enter') {if (pending != '') {pending = ''value = keptapply(kept)show(year, month)}document.activeElement.blur()}}on rangePrev(e) { show(month == 1 ? year - 1 : year, month == 1 ? 12 : month - 1) }on rangeNext(e) { show(month == 12 ? year + 1 : year, month == 12 ? 1 : month + 1) }on rangePick(d) {if (pending == '') {kept = start != '' ? start + sep + end : ''pending = dstart = dend = ''value = d + sepinvalidText = 'false'show(year, month)return null}a = keyOf(pending) <= keyOf(d) ? pending : db = keyOf(pending) <= keyOf(d) ? d : pendingpending = ''start = aend = bvalue = a + sep + bkept = valueshow(year, month)document.activeElement.blur()}
Branches
- mainmain branch
Latest commits
- 3bc905c5components #15 (mission 003) 2/2: docs (README Look, STATUS, LOG), reportmre
- 5a98d77ecomponents #15 (mission 003) 1/2: black/white base look — greys for nuances, no WorldAPI fallbacks, no decorative frames; site + icon black/white; gate 230/0mre
- 3f93d716components code order (mission 002) 4/4: docs, tests/letcount.py, tests/same-output*, reportmre
- e747cfa0components code order (mission 002) 3/4: let only where reassigned (257 dropped, 0 never-reassigned left); gate 220/0, layouts gate 73/0, same outputmre
- 172914a3components code order (mission 002) 2/4: lib/uploads.hl (demo upload storage), thin demo handlers, project.hl map; gate 220/0, same outputmre
- d29f35b1components code order (mission 002) 1/4: styles.hl -> components/styles.hl; gate 220/0, same outputmre
- 09945750components: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, http1 773de63e); gate 220/0mre
- ede4d19acomponents: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 220/0mre
- 2bb9a935components: Hybriel master 8efba065 (re-vendor round 069: #126 memory, #48 lambda copy)mre
- e4f01768antcolony#40: mission references point to the moved missionsmre
- d32b44e2antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 0fb80b57components: Hybriel master ff51cf46 (re-vendor round)mre
- 57408b6ecomponents#14: installable app (manifest, service worker, offline index), own iconmre
- 317bd09fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- becbd59bcomponents#13: modal dialog (<dialog> based, focus wrap, backdrop/Esc/x close with reason, scroll lock, footer buttons)mre
- 367dd19adeploy.sh: never send .git or .gitignore to Byrodinmre
- 4b4dabe5State of 2026-09-27, before the move to gitoriamre