gitoriaLog in with ident

components

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain3bc905c5components #15 (mission 003) 2/2: docs (README Look, STATUS, LOG), reportmremain/components/date-picker/date-picker.hl

10.4 KB

  1. // date-picker.hl — THE DATE PICKER for input fields, all Hybriel (no JavaScript file): `DatePicker { names = "arrival,departure" }`
  2. // 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.
  3. // Typing works too. Each field posts in a form by its name. The month grid is built here (plain integers, so the server render
  4. // and the browser agree); the browser's own today and language come in when a field gets focus (`Date` and `Intl` are browser
  5. // globals of View handlers). The popup is shown by CSS while its field or the popup has focus (:focus-within), so no `if`
  6. // re-creates the DOM. Use ONE `DatePicker` per page (a composed component's members are the page's state) — give it all the fields.
  7. import { now, timestamp } from 'hl:time'
  8. // the server's today (UTC) for the first render only; the browser corrects it when a field gets focus
  9. serverToday = () => { return timestamp(now()).slice(0, 10) }
  10. String names = 'date'
  11. Hybrid fields = names.split(',')
  12. Hybrid values = blanks(fields)
  13. Hybrid rows = makeRows(fields)
  14. String today = serverToday()
  15. Number year = toNumber(today.slice(0, 4))
  16. Number month = toNumber(today.slice(5, 7))
  17. Hybrid monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
  18. Hybrid weekdayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
  19. Hybrid weekdays = weekdayNames
  20. String title = monthNames[month - 1] + ' ' + year
  21. String selected = ''
  22. Hybrid days = build(year, month, today, '')
  23. String prevLabel = 'Previous month'
  24. String nextLabel = 'Next month'
  25. String placeholder = 'YYYY-MM-DD'
  26. blanks(list) {
  27. out = []
  28. let i = 0
  29. while (i < list.length) { out.push('') i = i + 1 }
  30. return out
  31. }
  32. // `rows` is never reassigned: re-rendering the rows would re-create the input elements, and a re-created input on a slow
  33. // round trip shows an empty value although its attribute is set. A field's text and its invalid mark are written to the
  34. // element itself (see field()) instead of being bound.
  35. makeRows(list) {
  36. out = []
  37. let i = 0
  38. while (i < list.length) {
  39. out.push({ name = list[i] hint = list[i] + ', ' + placeholder })
  40. i = i + 1
  41. }
  42. return out
  43. }
  44. pad(n) { return n < 10 ? '0' + n : '' + n }
  45. leap(y) { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 }
  46. daysIn(y, m) {
  47. if (m == 2) { return leap(y) ? 29 : 28 }
  48. if (m == 4 || m == 6 || m == 9 || m == 11) { return 30 }
  49. return 31
  50. }
  51. // 0 = Monday … 6 = Sunday (Sakamoto's method gives 0 = Sunday)
  52. weekdayOf(y, m, d) {
  53. t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
  54. yy = m < 3 ? y - 1 : y
  55. sun = (yy + (yy - yy % 4) / 4 - (yy - yy % 100) / 100 + (yy - yy % 400) / 400 + t[m - 1] + d) % 7
  56. return (sun + 6) % 7
  57. }
  58. // "YYYY-MM-DD" → yyyymmdd as a number (strings have no ordering operators), or 0 when it is not a real day
  59. keyOf(s) {
  60. if (s.length != 10 || s.slice(4, 5) != '-' || s.slice(7, 8) != '-') { return 0 }
  61. y = toNumber(s.slice(0, 4))
  62. m = toNumber(s.slice(5, 7))
  63. d = toNumber(s.slice(8, 10))
  64. if (y == null || m == null || d == null) { return 0 }
  65. if (m < 1 || m > 12 || d < 1 || d > daysIn(y, m)) { return 0 }
  66. if (('' + (10000 + y)).slice(1) + '-' + pad(m) + '-' + pad(d) != s) { return 0 }
  67. return y * 10000 + m * 100 + d
  68. }
  69. // 42 days (six weeks of seven) so the popup keeps its height; each carries its state as a class
  70. build(y, m, todayText, chosen) {
  71. py = m == 1 ? y - 1 : y
  72. pm = m == 1 ? 12 : m - 1
  73. ny = m == 12 ? y + 1 : y
  74. nm = m == 12 ? 1 : m + 1
  75. lead = weekdayOf(y, m, 1)
  76. let n = daysIn(y, m)
  77. pn = daysIn(py, pm)
  78. out = []
  79. let day = 1 - lead
  80. let i = 0
  81. while (i < 42) {
  82. let cy = y
  83. let cm = m
  84. let cd = day
  85. let cls = ''
  86. if (day < 1) { cy = py cm = pm cd = pn + day cls = 'outside' }
  87. if (day > n) { cy = ny cm = nm cd = day - n cls = 'outside' }
  88. let date = ('' + (10000 + cy)).slice(1) + '-' + pad(cm) + '-' + pad(cd)
  89. if (date == todayText) { cls = cls + ' today' }
  90. if (date == chosen) { cls = cls + ' selected' }
  91. out.push({ n = cd date = date cls = cls })
  92. day = day + 1
  93. i = i + 1
  94. }
  95. return out
  96. }
  97. show(y, m) {
  98. year = y
  99. month = m
  100. title = monthNames[m - 1] + ' ' + y
  101. days = build(y, m, today, selected)
  102. }
  103. // the row of a field name
  104. indexOf(name) {
  105. let i = 0
  106. while (i < fields.length) {
  107. if (fields[i] == name) { return i }
  108. i = i + 1
  109. }
  110. return 0
  111. }
  112. // the input element of a field name
  113. field(name) { return document.querySelector('date-picker input[name="' + name + '"]') }
  114. // a field's text changed (typed or picked): keep it, mark it when it is not a real day, show its month
  115. setValue(name, text, showMonth) {
  116. i = indexOf(name)
  117. k = keyOf(text)
  118. values[i] = text
  119. el = field(name)
  120. if (el.value != text) { el.value = text }
  121. el.setAttribute('aria-invalid', text != '' && k == 0 ? 'true' : 'false')
  122. selected = k != 0 ? text : ''
  123. if (k != 0 && showMonth) { show(toNumber(text.slice(0, 4)), toNumber(text.slice(5, 7))) } else { show(year, month) }
  124. }
  125. // 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
  126. // 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
  127. // theme) restyles it. No frames: a border or ring only where it means something (focus, today, an invalid field). A component's browser module
  128. // cannot import tokens itself.
  129. Style {
  130. 'date-picker' { display = 'grid' gap = '.75rem' maxWidth = '18rem' }
  131. 'date-field' { position = 'relative' display = 'block' }
  132. 'date-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' }
  133. 'date-picker input:focus' { outline = '2px solid var(--color-accent, #555)' outlineOffset = '-1px' }
  134. 'date-picker input[aria-invalid="true"]' { outline = '2px dashed var(--color-danger, #000)' outlineOffset = '-2px' }
  135. 'date-picker picker-popup' { display = 'none' position = 'absolute' top = '100%' left = '0' zIndex = '4' width = 'min(22rem, 90vw)' marginTop = '.25rem' outline = 'none' }
  136. 'date-field:focus-within picker-popup' { display = 'block' }
  137. 'date-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)' }
  138. 'date-picker month-view header' { display = 'flex' alignItems = 'center' justifyContent = 'space-between' gap = '.5rem' }
  139. 'date-picker month-view h2' { margin = '0' fontSize = '1.1rem' color = 'var(--color-text-strong, #000)' textAlign = 'center' flex = '1' textTransform = 'capitalize' }
  140. 'date-picker month-view ol' { display = 'grid' gridTemplateColumns = 'repeat(7, minmax(0, 1fr))' gap = '.2rem' margin = '0' padding = '0' listStyle = 'none' }
  141. 'date-picker button' { font = 'inherit' fontWeight = '600' padding = '.4rem .9rem' color = 'var(--color-text, #000)' background = 'transparent' border = '0' borderRadius = '.4rem' cursor = 'pointer' }
  142. 'date-picker button:hover' { background = 'var(--color-surface, #f2f2f2)' }
  143. 'date-picker ol.weekdays li' { padding = '.3rem 0' fontSize = '.8rem' fontWeight = '600' color = 'var(--color-text-muted, #555)' textAlign = 'center' }
  144. 'date-picker ol.days li' { minHeight = '2rem' padding = '.3rem' border = '0' borderRadius = '.3rem' textAlign = 'right' background = 'var(--color-surface, #f2f2f2)' cursor = 'pointer' }
  145. 'date-picker ol.days li:hover' { background = 'var(--color-background-sunken, #ddd)' }
  146. 'date-picker ol.days time' { color = 'var(--color-text, #000)' fontSize = '.95rem' }
  147. 'date-picker ol.days li.outside' { background = 'var(--color-background, #fff)' }
  148. 'date-picker ol.days li.outside time' { color = 'var(--color-text-muted, #555)' }
  149. 'date-picker ol.days li.today' { outline = '2px solid var(--color-accent, #000)' outlineOffset = '-2px' }
  150. 'date-picker ol.days li.selected' { background = 'var(--color-accent, #000)' }
  151. 'date-picker ol.days li.selected time' { color = 'var(--color-accent-text, #fff)' }
  152. }
  153. View {
  154. datePicker {
  155. for (r of rows) {
  156. dateField {
  157. 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) } }
  158. pickerPopup { tabindex = "-1"
  159. monthView {
  160. header {
  161. button { type = "button" class = "quiet prev" title = prevLabel "‹" on click(e) { emit datePrev(e) } }
  162. h2 { title }
  163. button { type = "button" class = "quiet next" title = nextLabel "›" on click(e) { emit dateNext(e) } }
  164. }
  165. ol { class = "weekdays" for (wd of weekdays) { li { wd } } }
  166. 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) } } } }
  167. }
  168. }
  169. }
  170. }
  171. }
  172. }
  173. // the browser knows today and its language; a field gets focus first, so this runs before a day is clicked
  174. on dateOpen(name) {
  175. let now = new Date()
  176. lang = navigator.language != null ? navigator.language : 'en'
  177. t = ('' + (10000 + now.getFullYear())).slice(1) + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())
  178. if (t != today) {
  179. mf = Intl.DateTimeFormat(lang, { month = 'long' })
  180. df = Intl.DateTimeFormat(lang, { weekday = 'short' })
  181. ms = []
  182. ds = []
  183. let i = 0
  184. while (i < 12) { ms.push(mf.format(new Date(Date.UTC(2024, i, 15, 12)))) i = i + 1 }
  185. i = 0
  186. while (i < 7) { ds.push(df.format(new Date(Date.UTC(2024, 0, 1 + i, 12)))) i = i + 1 }
  187. today = t
  188. monthNames = ms
  189. weekdayNames = ds
  190. weekdays = ds
  191. de = lang.slice(0, 2) == 'de'
  192. prevLabel = de ? 'Voriger Monat' : 'Previous month'
  193. nextLabel = de ? 'Nächster Monat' : 'Next month'
  194. }
  195. text = values[indexOf(name)]
  196. selected = keyOf(text) != 0 ? text : ''
  197. 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))) }
  198. }
  199. on dateTyped(name, text) { setValue(name, text, true) }
  200. on dateKey(key) {
  201. if (key == 'Escape' || key == 'Enter') { document.activeElement.blur() }
  202. }
  203. on datePrev(e) { show(month == 1 ? year - 1 : year, month == 1 ? 12 : month - 1) }
  204. on dateNext(e) { show(month == 12 ? year + 1 : year, month == 12 ? 1 : month + 1) }
  205. on datePick(name, d) {
  206. setValue(name, d, false)
  207. document.activeElement.blur()
  208. }

Branches

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