components
All repositories: gitoria
10.5 KB
// date-picker.hl — THE DATE PICKER for input fields, all Hybriel (no JavaScript file): `DatePicker { names = "arrival,departure" }`// makes one text field per name; click a field, a month opens under it, click a day and the field shows it as YYYY-MM-DD.// Typing works too. Each field posts in a form by its name. The month grid is built here (plain integers, so the server render// and the browser agree); the browser's own today and language come in when a field gets focus (`Date` and `Intl` are browser// globals of View handlers). The popup is shown by CSS while its field or the popup has focus (:focus-within), so no `if`// re-creates the DOM. Use ONE `DatePicker` per page (a composed component's members are the page's state) — give it all the fields.import { now, timestamp } from 'hl:time'// the server's today (UTC) for the first render only; the browser corrects it when a field gets focusserverToday = () => { return timestamp(now()).slice(0, 10) }String names = 'date'Hybrid fields = names.split(',')Hybrid values = blanks(fields)Hybrid rows = makeRows(fields)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] + ' ' + yearString selected = ''Hybrid days = build(year, month, today, '')String prevLabel = 'Previous month'String nextLabel = 'Next month'String placeholder = 'YYYY-MM-DD'blanks(list) {let out = []let i = 0while (i < list.length) { out.push('') i = i + 1 }return out}// `rows` is never reassigned: re-rendering the rows would re-create the input elements, and a re-created input on a slow// round trip shows an empty value although its attribute is set. A field's text and its invalid mark are written to the// element itself (see field()) instead of being bound.makeRows(list) {let out = []let i = 0while (i < list.length) {out.push({ name = list[i] hint = list[i] + ', ' + placeholder })i = i + 1}return out}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) {let t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]let yy = m < 3 ? y - 1 : ylet sun = (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 }let y = toNumber(s.slice(0, 4))let m = toNumber(s.slice(5, 7))let 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, chosen) {let py = m == 1 ? y - 1 : ylet pm = m == 1 ? 12 : m - 1let ny = m == 12 ? y + 1 : ylet nm = m == 12 ? 1 : m + 1let lead = weekdayOf(y, m, 1)let n = daysIn(y, m)let pn = daysIn(py, pm)let 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)if (date == todayText) { cls = cls + ' today' }if (date == chosen) { 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, selected)}// the row of a field nameindexOf(name) {let i = 0while (i < fields.length) {if (fields[i] == name) { return i }i = i + 1}return 0}// the input element of a field namefield(name) { return document.querySelector('date-picker input[name="' + name + '"]') }// a field's text changed (typed or picked): keep it, mark it when it is not a real day, show its monthsetValue(name, text, showMonth) {let i = indexOf(name)let k = keyOf(text)values[i] = textlet el = field(name)if (el.value != text) { el.value = text }el.setAttribute('aria-invalid', text != '' && k == 0 ? 'true' : 'false')selected = k != 0 ? text : ''if (k != 0 && showMonth) { show(toNumber(text.slice(0, 4)), toNumber(text.slice(5, 7))) } else { show(year, month) }}// Colours: the semantic WorldAPI tokens as css variables, each with its palette value as fallback, so a copy of this file is styled in an app that declares no tokens (an app that imports shared/tokens.hl in its styles.hl and sets --color-accent wins). A component's browser module cannot import tokens.hl itself.Style {'date-picker' { display = 'grid' gap = '.75rem' maxWidth = '18rem' }'date-field' { position = 'relative' display = 'block' }'date-picker input' { width = '100%' boxSizing = 'border-box' font = 'inherit' padding = '.4rem .6rem' color = 'var(--color-text, rgb(195, 200, 205))' background = 'var(--color-background-sunken, rgb(15, 20, 25))' border = '1px solid var(--color-border, rgb(70, 75, 80))' borderRadius = '.4rem' }'date-picker input:focus' { outline = '2px solid var(--color-accent, #569bd4)' outlineOffset = '-1px' }'date-picker input[aria-invalid="true"]' { borderColor = 'var(--color-danger, #f44747)' }'date-picker picker-popup' { display = 'none' position = 'absolute' top = '100%' left = '0' zIndex = '4' width = 'min(22rem, 90vw)' marginTop = '.25rem' outline = 'none' }'date-field:focus-within picker-popup' { display = 'block' }'date-picker month-view' { display = 'grid' gap = '.5rem' padding = '.5rem' background = 'var(--color-background, rgb(25, 30, 35))' border = '1px solid var(--color-border, rgb(70, 75, 80))' borderRadius = '.5rem' }'date-picker month-view header' { display = 'flex' alignItems = 'center' justifyContent = 'space-between' gap = '.5rem' }'date-picker month-view h2' { margin = '0' fontSize = '1.1rem' color = 'var(--color-text-strong, rgb(245, 250, 255))' textAlign = 'center' flex = '1' textTransform = 'capitalize' }'date-picker month-view ol' { display = 'grid' gridTemplateColumns = 'repeat(7, minmax(0, 1fr))' margin = '0' padding = '0' listStyle = 'none' }'date-picker button' { font = 'inherit' fontWeight = '600' padding = '.4rem .9rem' color = 'var(--color-text, rgb(195, 200, 205))' background = 'transparent' border = '1px solid var(--color-border, rgb(70, 75, 80))' borderRadius = '.4rem' cursor = 'pointer' }'date-picker button:hover' { background = 'var(--color-surface, rgb(35, 40, 45))' }'date-picker ol.weekdays li' { padding = '.3rem 0' fontSize = '.8rem' fontWeight = '600' color = 'var(--color-text-muted, rgb(145, 150, 155))' textAlign = 'center' }'date-picker ol.days li' { minHeight = '2rem' padding = '.3rem' border = '1px solid var(--color-border, rgb(70, 75, 80))' textAlign = 'right' background = 'var(--color-surface, rgb(35, 40, 45))' cursor = 'pointer' }'date-picker ol.days li:hover' { background = 'var(--color-background-sunken, rgb(15, 20, 25))' }'date-picker ol.days time' { color = 'var(--color-text, rgb(195, 200, 205))' fontSize = '.95rem' }'date-picker ol.days li.outside' { background = 'var(--color-background, rgb(25, 30, 35))' }'date-picker ol.days li.outside time' { color = 'var(--color-text-muted, rgb(145, 150, 155))' }'date-picker ol.days li.today' { outline = '2px solid var(--color-accent, #569bd4)' outlineOffset = '-2px' }'date-picker ol.days li.selected' { background = 'var(--color-accent, #569bd4)' }'date-picker ol.days li.selected time' { color = 'var(--color-accent-text, rgb(15, 20, 25))' }}View {datePicker {for (r of rows) {dateField {input { name = r.name type = "text" inputmode = "numeric" autocomplete = "off" maxlength = "10" title = r.hint placeholder = placeholder 'aria-invalid' = "false" on focus(e) { emit dateOpen(r.name) } on input(e) { emit dateTyped(r.name, e.target.value) } on keydown(e) { emit dateKey(e.key) } }pickerPopup { tabindex = "-1"monthView {header {button { type = "button" class = "quiet prev" title = prevLabel "‹" on click(e) { emit datePrev(e) } }h2 { title }button { type = "button" class = "quiet next" title = nextLabel "›" on click(e) { emit dateNext(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 datePick(r.name, d.date) } } } }}}}}}}// the browser knows today and its language; a field gets focus first, so this runs before a day is clickedon dateOpen(name) {let now = new Date()let lang = navigator.language != null ? navigator.language : 'en'let t = ('' + (10000 + now.getFullYear())).slice(1) + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())if (t != today) {let mf = Intl.DateTimeFormat(lang, { month = 'long' })let df = Intl.DateTimeFormat(lang, { weekday = 'short' })let ms = []let 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 = dslet de = lang.slice(0, 2) == 'de'prevLabel = de ? 'Voriger Monat' : 'Previous month'nextLabel = de ? 'Nächster Monat' : 'Next month'}let text = values[indexOf(name)]selected = keyOf(text) != 0 ? text : ''if (selected != '') { show(toNumber(text.slice(0, 4)), toNumber(text.slice(5, 7))) } else { show(toNumber(today.slice(0, 4)), toNumber(today.slice(5, 7))) }}on dateTyped(name, text) { setValue(name, text, true) }on dateKey(key) {if (key == 'Escape' || key == 'Enter') { document.activeElement.blur() }}on datePrev(e) { show(month == 1 ? year - 1 : year, month == 1 ? 12 : month - 1) }on dateNext(e) { show(month == 12 ? year + 1 : year, month == 12 ? 1 : month + 1) }on datePick(name, d) {setValue(name, d, false)document.activeElement.blur()}
Branches
- mainmain branch
Latest commits
- 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