style(web): polish native date, time and file inputs

This commit is contained in:
2026-07-12 12:55:29 +02:00
parent 0e8f013bbb
commit 1dfbf67921
+593
View File
@@ -554,6 +554,271 @@
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
/* --- Native form control polish (date / time / file) --- */
/* HTML5 date and time inputs have a native popup that is drawn by
the OS and cannot be themed. We polish the input itself so it
matches the rest of the design and replace the OS picker
indicator with a themed one. The JS below replaces the popup
with a fully custom one when JavaScript runs. */
input[type="date"],
input[type="time"],
input[type="datetime-local"] {
font: inherit;
font-size: 0.85rem;
color: var(--fg);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.55rem 0.75rem;
width: 100%;
font-feature-settings: normal;
transition: border-color 0.12s, box-shadow 0.12s;
}
input[type="date"]:focus,
input[type="time"]:focus,
input[type="datetime-local"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--ring);
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
opacity: 0.5;
cursor: pointer;
padding: 0 0.2rem;
margin: 0;
filter: invert(0.45);
transition: opacity 0.12s;
}
input[type="date"]:hover::-webkit-calendar-picker-indicator,
input[type="time"]:hover::-webkit-calendar-picker-indicator,
input[type="datetime-local"]:hover::-webkit-calendar-picker-indicator {
opacity: 1;
}
@media (prefers-color-scheme: dark) {
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
filter: invert(0.85);
}
}
/* Firefox draws a small inner "X" clear button — tone it down. */
input[type="date"]::-moz-clear,
input[type="time"]::-moz-clear { opacity: 0.5; }
input[type="file"] {
font: inherit;
font-size: 0.82rem;
color: var(--fg-muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
width: 100%;
cursor: pointer;
transition: border-color 0.12s, box-shadow 0.12s;
}
input[type="file"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--ring);
}
input[type="file"]::file-selector-button {
font: inherit;
font-size: 0.82rem;
font-weight: 550;
padding: 0.4rem 0.75rem;
margin-right: 0.7rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--fg);
cursor: pointer;
font-family: inherit;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
input[type="file"]::file-selector-button:hover {
background: var(--surface);
border-color: var(--border-strong);
}
input[type="file"]::file-selector-button:active { transform: translateY(1px); }
/* Firefox variant of the file selector button pseudo-element. */
input[type="file"]::-webkit-file-upload-button {
font: inherit;
font-size: 0.82rem;
font-weight: 550;
padding: 0.4rem 0.75rem;
margin-right: 0.7rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--fg);
cursor: pointer;
font-family: inherit;
}
/* --- Custom date picker (progressive enhancement) --- */
.date-input { position: relative; }
.date-input__native { display: none; }
.date-input__btn {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
font: inherit;
font-size: 0.85rem;
text-align: left;
background: var(--surface);
color: var(--fg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.55rem 0.75rem;
cursor: pointer;
font-family: inherit;
transition: border-color 0.12s, box-shadow 0.12s, background 0.12s;
}
.date-input__btn:hover { border-color: var(--border-strong); }
.date-input__btn:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: var(--ring);
}
.date-input__btn[data-empty="1"] { color: var(--fg-subtle); }
.date-input__label { flex: 1; min-width: 0; }
.date-input__icon {
display: inline-flex;
color: var(--fg-muted);
flex-shrink: 0;
}
.date-input__icon svg { width: 16px; height: 16px; display: block; }
.date-input__popover {
position: absolute;
z-index: 20;
top: calc(100% + 6px);
left: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 0.85rem;
box-shadow: var(--shadow-lg);
min-width: 280px;
user-select: none;
}
.date-input__nav {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.date-input__nav-title {
flex: 1;
text-align: center;
font-size: 0.9rem;
font-weight: 600;
color: var(--fg);
letter-spacing: -0.01em;
}
.date-input__nav-btn {
width: 28px;
height: 28px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--fg-muted);
cursor: pointer;
font-family: inherit;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.date-input__nav-btn:hover {
background: var(--surface-2);
color: var(--fg);
border-color: var(--border-strong);
}
.date-input__nav-btn svg { width: 14px; height: 14px; }
.date-input__weekdays,
.date-input__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.date-input__weekday {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
color: var(--fg-subtle);
text-align: center;
padding: 0.35rem 0 0.4rem;
letter-spacing: 0.04em;
}
.date-input__day {
height: 32px;
display: grid;
place-items: center;
font: inherit;
font-size: 0.82rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--fg);
cursor: pointer;
font-family: inherit;
transition: background 0.1s, color 0.1s;
}
.date-input__day:hover { background: var(--surface-2); }
.date-input__day[data-other-month="1"] { color: var(--fg-subtle); }
.date-input__day[data-today="1"] {
border: 1px solid var(--border-strong);
}
.date-input__day[data-selected="1"] {
background: var(--accent);
color: var(--on-accent);
}
.date-input__day[data-selected="1"]:hover { background: var(--accent-hover); }
.date-input__day:focus-visible {
outline: none;
box-shadow: var(--ring);
}
.date-input__footer {
display: flex;
gap: 0.4rem;
margin-top: 0.85rem;
padding-top: 0.65rem;
border-top: 1px solid var(--border);
}
.date-input__footer-btn {
font: inherit;
font-size: 0.8rem;
font-weight: 550;
flex: 1;
padding: 0.4rem 0.6rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--fg-muted);
cursor: pointer;
font-family: inherit;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.date-input__footer-btn:hover {
background: var(--surface-2);
color: var(--fg);
border-color: var(--border-strong);
}
.date-input__footer-btn--primary {
background: var(--accent);
color: var(--on-accent);
border-color: var(--accent);
}
.date-input__footer-btn--primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: var(--on-accent);
}
</style>
</head>
<body>
@@ -660,5 +925,333 @@
</div>
</div>
<% end %>
<script>
/* Custom date picker — replaces <input type="date" class="input"> with
a styled trigger that opens a popover calendar matching the design
system. The native input is hidden but kept in the DOM so form
submission works the same way with or without JavaScript. */
(function () {
var MONTH_CACHE = {};
var WEEK_CACHE = {};
var FIRSTDAY_CACHE = {};
function langFor(node) {
var n = node;
while (n && n.nodeType === 1) {
var l = n.getAttribute("lang");
if (l) return l;
n = n.parentNode;
}
return document.documentElement.lang || "en";
}
function monthNames(lang) {
if (!MONTH_CACHE[lang]) {
try {
var fmt = new Intl.DateTimeFormat(lang, { month: "long" });
MONTH_CACHE[lang] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map(function (i) {
return fmt.format(new Date(2000, i, 15));
});
} catch (e) {
MONTH_CACHE[lang] = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
}
}
return MONTH_CACHE[lang];
}
function weekdayNames(lang, firstDay) {
var key = lang + "|" + firstDay;
if (!WEEK_CACHE[key]) {
try {
var fmt = new Intl.DateTimeFormat(lang, { weekday: "short" });
var base = [1, 2, 3, 4, 5, 6, 7]; // 2001-01-01 was Monday, so 1..7 = Mon..Sun
var all = base.map(function (d) { return fmt.format(new Date(2001, 0, d)); });
var offset = firstDay - 1;
WEEK_CACHE[key] = all.slice(offset).concat(all.slice(0, offset));
} catch (e) {
WEEK_CACHE[key] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
}
}
return WEEK_CACHE[key];
}
function firstDay(lang) {
if (!FIRSTDAY_CACHE[lang]) {
try {
var info = new Intl.Locale(lang).getWeekInfo();
FIRSTDAY_CACHE[lang] = (info && typeof info.firstDay === "number")
? info.firstDay : 1;
} catch (e) {
var monFirst = {
cs: 1, sk: 1, sl: 1, hr: 1, sr: 1, bs: 1, de: 1, fr: 1, es: 1,
it: 1, nl: 1, pl: 1, pt: 1, ru: 1, uk: 1, sq: 1, sv: 1, no: 1, da: 1
};
FIRSTDAY_CACHE[lang] = monFirst[lang] || 7;
}
}
return FIRSTDAY_CACHE[lang];
}
function formatDate(lang, d) {
try {
return new Intl.DateTimeFormat(lang, {
day: "numeric", month: "long", year: "numeric"
}).format(d);
} catch (e) {
return d.toDateString();
}
}
function parseISO(s) {
if (!s || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
var p = s.split("-");
var d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
return isNaN(d.getTime()) ? null : d;
}
function toISO(d) {
var y = d.getFullYear();
var m = String(d.getMonth() + 1).padStart(2, "0");
var dd = String(d.getDate()).padStart(2, "0");
return y + "-" + m + "-" + dd;
}
function sameDay(a, b) {
return !!a && !!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
}
function todayMidnight() {
var n = new Date();
return new Date(n.getFullYear(), n.getMonth(), n.getDate());
}
function svg(icon) {
var paths = {
chevronLeft: '<polyline points="15 18 9 12 15 6"/>',
chevronRight: '<polyline points="9 18 15 12 9 6"/>',
calendar:
'<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>' +
'<line x1="16" y1="2" x2="16" y2="6"/>' +
'<line x1="8" y1="2" x2="8" y2="6"/>' +
'<line x1="3" y1="10" x2="21" y2="10"/>'
};
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" '
+ 'aria-hidden="true">' + (paths[icon] || "") + "</svg>";
}
function buildPicker(input) {
if (input.dataset.volumenPicker === "1") return;
input.dataset.volumenPicker = "1";
var lang = langFor(input);
var initial = parseISO(input.value || "");
var weekdayStart = firstDay(lang);
var wrap = document.createElement("div");
wrap.className = "date-input";
var button = document.createElement("button");
button.type = "button";
button.className = "date-input__btn";
button.setAttribute("aria-haspopup", "dialog");
button.setAttribute("aria-expanded", "false");
var labelEl = document.createElement("span");
labelEl.className = "date-input__label";
button.appendChild(labelEl);
var iconEl = document.createElement("span");
iconEl.className = "date-input__icon";
iconEl.innerHTML = svg("calendar");
button.appendChild(iconEl);
var pop = document.createElement("div");
pop.className = "date-input__popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", "Pick a date");
pop.hidden = true;
pop.innerHTML =
'<div class="date-input__nav">' +
'<button type="button" class="date-input__nav-btn" data-prev '
+ 'aria-label="Previous month">' + svg("chevronLeft") + "</button>" +
'<div class="date-input__nav-title"></div>' +
'<button type="button" class="date-input__nav-btn" data-next '
+ 'aria-label="Next month">' + svg("chevronRight") + "</button>" +
"</div>" +
'<div class="date-input__weekdays"></div>' +
'<div class="date-input__days"></div>' +
'<div class="date-input__footer">' +
'<button type="button" class="date-input__footer-btn" data-clear>'
+ (lang === "cs" ? "Vymazat" : "Clear") + "</button>" +
'<button type="button" class="date-input__footer-btn '
+ 'date-input__footer-btn--primary" data-today>'
+ (lang === "cs" ? "Dnes" : "Today") + "</button>" +
"</div>";
var weekdaysEl = pop.querySelector(".date-input__weekdays");
var daysEl = pop.querySelector(".date-input__days");
var titleEl = pop.querySelector(".date-input__nav-title");
var viewYear, viewMonth, selected = initial;
function resetView() {
var ref = selected || todayMidnight();
viewYear = ref.getFullYear();
viewMonth = ref.getMonth();
}
resetView();
function renderLabel() {
if (selected) {
labelEl.textContent = formatDate(lang, selected);
button.dataset.empty = "0";
} else {
labelEl.textContent = input.placeholder || (lang === "cs" ? "Vyberte datum" : "Pick a date");
button.dataset.empty = "1";
}
}
function renderMonth() {
titleEl.textContent = monthNames(lang)[viewMonth] + " " + viewYear;
weekdaysEl.innerHTML = weekdayNames(lang, weekdayStart)
.map(function (w) { return '<div class="date-input__weekday">' + w + "</div>"; })
.join("");
var first = new Date(viewYear, viewMonth, 1);
var firstWd = ((first.getDay() - weekdayStart + 7) % 7) + 1; // 1..7
var start = new Date(viewYear, viewMonth, 1 - (firstWd - 1));
var today = todayMidnight();
var cells = [];
for (var i = 0; i < 42; i++) {
var d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
var attrs = ' class="date-input__day" data-iso="' + toISO(d) + '"';
if (d.getMonth() !== viewMonth) attrs += ' data-other-month="1"';
if (sameDay(d, today)) attrs += ' data-today="1"';
if (sameDay(d, selected)) attrs += ' data-selected="1"';
attrs += ' tabindex="-1" type="button"';
cells.push("<button" + attrs + ">" + d.getDate() + "</button>");
}
daysEl.innerHTML = cells.join("");
}
function commit(d) {
selected = d;
input.value = toISO(d);
renderLabel();
renderMonth();
input.dispatchEvent(new Event("change", { bubbles: true }));
}
function open() {
if (!pop.hidden) return;
resetView();
renderMonth();
pop.hidden = false;
button.setAttribute("aria-expanded", "true");
}
function close() {
if (pop.hidden) return;
pop.hidden = true;
button.setAttribute("aria-expanded", "false");
}
function onDocClick(e) {
if (!wrap.contains(e.target)) close();
}
function onDocKey(e) {
if (e.key === "Escape" && !pop.hidden) {
close();
button.focus();
}
}
button.addEventListener("click", function (e) {
e.preventDefault();
if (pop.hidden) open(); else close();
});
pop.addEventListener("click", function (e) {
var day = e.target.closest(".date-input__day");
if (day && day.dataset.iso) {
e.preventDefault();
var d = parseISO(day.dataset.iso);
if (d) { commit(d); close(); }
return;
}
if (e.target.closest("[data-prev]")) {
e.preventDefault();
if (--viewMonth < 0) { viewMonth = 11; viewYear--; }
renderMonth();
return;
}
if (e.target.closest("[data-next]")) {
e.preventDefault();
if (++viewMonth > 11) { viewMonth = 0; viewYear++; }
renderMonth();
return;
}
if (e.target.closest("[data-clear]")) {
e.preventDefault();
selected = null;
input.value = "";
renderLabel();
renderMonth();
input.dispatchEvent(new Event("change", { bubbles: true }));
close();
return;
}
if (e.target.closest("[data-today]")) {
e.preventDefault();
var t = todayMidnight();
commit(t);
close();
}
});
document.addEventListener("mousedown", onDocClick, true);
document.addEventListener("keydown", onDocKey, true);
input.parentNode.insertBefore(wrap, input);
wrap.appendChild(input);
input.classList.add("date-input__native");
wrap.appendChild(button);
wrap.appendChild(pop);
renderLabel();
}
function enhanceAll(root) {
(root || document).querySelectorAll(
'input.input[type="date"], input.input[type="datetime-local"]'
).forEach(buildPicker);
}
function init() {
enhanceAll();
// Re-scan on Turbolinks-style page swaps, should the admin adopt them.
if (typeof MutationObserver !== "undefined") {
var obs = new MutationObserver(function () {
enhanceAll(document);
});
obs.observe(document.body, { childList: true, subtree: true });
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>
</body>
</html>