Choice Cards
Cards to compare and pick, on real radio buttons or checkboxes, with a title, a line and an optional price. The picked card's ink border draws itself around it from the corner while a check draws in its mark. Pure CSS.
View codeA time field over the real input: a wheel that snaps and takes the time once it stops, or the free slots of a day.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Time Picker to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<label class="kodza-time" data-locale="en-GB">
<span class="kodza-time__label">Deploy at</span>
<input class="kodza-time__native" type="time" name="deploy" value="16:55" step="300">
</label>
<label class="kodza-time kodza-time--slots" data-locale="en-US" data-taken="10:00 11:30 12:30">
<span class="kodza-time__label">Review call</span>
<input class="kodza-time__native" type="time" name="call" min="09:00" max="13:00" step="1800">
</label>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Time Picker
A real time input, dressed up: the field shows the time the way the locale says it (12 or
24 hours), and a wheel unrolls under it. Hour and minute columns (and AM / PM) scroll and
snap on the value, read through an ink band where the digits turn white exactly at its
edges, and the time rolls into the field part by part. With .kodza-time--slots, the panel
shows the free slots of a day instead, taken ones struck out, and the ink mark travels to
the one you pick. Same box as Select and Date Picker. Everything is tuned with the
--kz-time-* variables. */
.kodza-time {
--kz-time-bg: #ffffff;
--kz-time-fg: #0a0a0a;
--kz-time-muted: rgba(10, 10, 10, 0.6);
--kz-time-line: rgba(10, 10, 10, 0.14);
--kz-time-line-hover: rgba(10, 10, 10, 0.3);
--kz-time-soft: rgba(10, 10, 10, 0.06);
--kz-time-ink: #0a0a0a;
--kz-time-on-ink: #ffffff;
--kz-time-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3);
--kz-time-height: 56px;
--kz-time-radius: calc(12px * var(--kz-radius-scale, 1));
--kz-time-pad: 16px;
/* One line of the wheel, and the width of one of its columns */
--kz-time-row: 36px;
--kz-time-col: 64px;
--kz-time-ease: cubic-bezier(0.22, 1, 0.36, 1);
--kz-time-width: var(--kz-field-width, 320px);
position: relative;
display: block;
width: 100%;
max-width: var(--kz-time-width);
/* The closed panel keeps its size, ready to unroll, but never lengthens the page */
contain: layout;
/* A number, not auto, so it can wait for the panel to roll up before dropping back */
z-index: 0;
transition: z-index 0s linear calc(0.4s * var(--kz-motion-scale, 1));
color: var(--kz-time-fg);
}
/* Open, the field rises above what follows it (its panel included) */
.kodza-time.is-open {
z-index: 20;
transition-delay: 0s;
}
.kodza-time__label {
position: absolute;
top: 10px;
left: var(--kz-time-pad);
z-index: 1;
color: var(--kz-time-muted);
font-size: 12px;
line-height: 1;
pointer-events: none;
}
.kodza-time__native,
.kodza-time__trigger {
display: flex;
align-items: flex-end;
width: 100%;
height: var(--kz-time-height);
margin: 0;
padding: 0 44px 9px var(--kz-time-pad);
border: 0;
border-radius: var(--kz-time-radius);
background: var(--kz-time-bg);
box-shadow: inset 0 0 0 1px var(--kz-time-line);
box-sizing: border-box;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
text-align: left;
cursor: pointer;
transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}
.kodza-time__native:hover,
.kodza-time__trigger:hover {
box-shadow: inset 0 0 0 1px var(--kz-time-line-hover);
}
.kodza-time__native:focus-visible,
.kodza-time__trigger:focus-visible,
.kodza-time.is-open .kodza-time__trigger {
outline: none;
box-shadow: inset 0 0 0 1.5px var(--kz-time-fg), 0 0 0 4px var(--kz-time-soft);
}
.kodza-time.is-ready .kodza-time__native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
clip-path: inset(50%);
}
/* The time in the field: each part (hours, minutes, AM / PM) rolls on its own, and its width
follows when the new face is wider or narrower */
.kodza-time__value,
.kodza-time__part {
display: inline-grid;
overflow: hidden;
}
.kodza-time__value > span,
.kodza-time__part > span {
grid-area: 1 / 1;
justify-self: start;
white-space: pre;
}
.kodza-time__value .is-empty {
color: var(--kz-time-muted);
}
.kodza-time__line {
display: inline-flex;
font-variant-numeric: tabular-nums;
}
.kodza-time__icon {
position: absolute;
top: calc(var(--kz-time-height) / 2 - 9px);
right: 16px;
width: 18px;
height: 18px;
fill: none;
stroke: var(--kz-time-muted);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
/* The panel unrolls under the field */
.kodza-time__pop {
position: absolute;
top: calc(var(--kz-time-height) + 8px);
left: 0;
z-index: 20;
width: 100%;
padding: 12px;
border-radius: calc(16px * var(--kz-radius-scale, 1));
background: var(--kz-time-bg);
box-shadow: 0 0 0 1px var(--kz-time-line), var(--kz-time-shadow);
box-sizing: border-box;
visibility: hidden;
opacity: 0;
clip-path: inset(0 0 100% 0 round calc(16px * var(--kz-radius-scale, 1)));
transform: translateY(-4px);
transition:
clip-path calc(0.4s * var(--kz-motion-scale, 1)) var(--kz-time-ease),
opacity calc(0.2s * var(--kz-motion-scale, 1)) ease,
transform calc(0.4s * var(--kz-motion-scale, 1)) var(--kz-time-ease),
visibility 0s linear calc(0.4s * var(--kz-motion-scale, 1));
}
.kodza-time.is-open .kodza-time__pop {
visibility: visible;
opacity: 1;
clip-path: inset(-40px -40px -60px -40px round calc(16px * var(--kz-radius-scale, 1)));
transform: none;
transition-delay: 0s;
}
.kodza-time.is-above .kodza-time__pop {
top: auto;
bottom: calc(var(--kz-time-height) + 8px);
clip-path: inset(100% 0 0 0 round calc(16px * var(--kz-radius-scale, 1)));
transform: translateY(4px);
}
.kodza-time.is-above.is-open .kodza-time__pop {
clip-path: inset(-60px -40px -40px -40px round calc(16px * var(--kz-radius-scale, 1)));
transform: none;
}
/* The wheel: five lines a column, the value on the middle one, under the ink band */
.kodza-time__wheel {
position: relative;
display: flex;
justify-content: center;
gap: 4px;
width: max-content;
margin: 0 auto;
padding: 0 6px;
}
.kodza-time__band {
position: absolute;
top: calc(var(--kz-time-row) * 2);
right: 0;
left: 0;
height: var(--kz-time-row);
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-time-ink);
pointer-events: none;
}
.kodza-time__col {
position: relative;
border-radius: var(--kz-time-radius);
}
.kodza-time__list {
position: relative;
width: var(--kz-time-col);
height: calc(var(--kz-time-row) * 5);
overflow-y: auto;
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
scrollbar-width: none;
touch-action: pan-y;
outline: none;
color: var(--kz-time-fg);
/* The far lines fade out, like the edge of a drum */
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.12), #000 calc(var(--kz-time-row) * 1.75), #000 calc(var(--kz-time-row) * 3.25), rgba(0, 0, 0, 0.12));
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.12), #000 calc(var(--kz-time-row) * 1.75), #000 calc(var(--kz-time-row) * 3.25), rgba(0, 0, 0, 0.12));
}
.kodza-time__list::-webkit-scrollbar {
display: none;
}
/* Room for the first and the last value to reach the band */
.kodza-time__list::before,
.kodza-time__list::after {
content: "";
display: block;
height: calc(var(--kz-time-row) * 2);
}
/* Sent to a value (click, keys), a column glides there by script, snapping off on the way */
.kodza-time__list.is-gliding {
scroll-snap-type: none;
}
.kodza-time__item,
.kodza-time__ghost {
display: grid;
place-items: center;
height: var(--kz-time-row);
font-size: 16px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.kodza-time__item {
scroll-snap-align: center;
cursor: pointer;
user-select: none;
}
/* The lens: a white copy of the column, cut to the band, so the digits turn white exactly at
its edges whatever the scroll */
.kodza-time__lens {
position: absolute;
top: calc(var(--kz-time-row) * 2);
right: 0;
left: 0;
height: var(--kz-time-row);
overflow: hidden;
border-radius: var(--kz-radius-pill, 999px);
color: var(--kz-time-on-ink);
pointer-events: none;
}
/* Focus from the keyboard: a ring round the value of the column, inside the band */
.kodza-time__col:has(> .kodza-time__list:focus-visible) .kodza-time__lens {
box-shadow: inset 0 0 0 3px var(--kz-time-ink), inset 0 0 0 4.5px var(--kz-time-on-ink);
}
.kodza-time__reel {
will-change: transform;
}
/* Out of min / max: still on the wheel, but faded, and the wheel slides back off it */
.kodza-time__item.is-off,
.kodza-time__ghost.is-off {
opacity: 0.3;
cursor: default;
}
.kodza-time__sep {
position: relative;
align-self: center;
width: 8px;
color: var(--kz-time-on-ink);
font-size: 16px;
font-weight: 500;
line-height: var(--kz-time-row);
text-align: center;
pointer-events: none;
}
.kodza-time__foot {
display: flex;
justify-content: flex-end;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--kz-time-soft);
}
.kodza-time__done {
display: grid;
place-items: center;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: none;
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, revert);
text-transform: var(--kz-font-label-case, revert);
cursor: pointer;
transition: background-color calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-time__done:hover {
background: var(--kz-time-soft);
}
/* The slots of a day */
.kodza-time__count {
margin: 2px 4px 10px;
color: var(--kz-time-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 11px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
}
.kodza-time__slots {
position: relative;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
/* Room for the focus ring, which the scroll box would clip */
margin: -4px;
padding: 4px;
max-height: 272px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.kodza-time__slot {
position: relative;
z-index: 1;
display: grid;
place-items: center;
height: 36px;
margin: 0;
padding: 0 6px;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: none;
box-shadow: inset 0 0 0 1px var(--kz-time-line);
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 500);
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, revert);
text-transform: var(--kz-font-label-case, revert);
white-space: nowrap;
cursor: pointer;
transition:
color calc(0.2s * var(--kz-motion-scale, 1)) ease calc(0.1s * var(--kz-motion-scale, 1)),
background-color calc(0.2s * var(--kz-motion-scale, 1)) ease,
box-shadow calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-time__slot:hover {
background: var(--kz-time-soft);
}
.kodza-time__slot[aria-pressed="true"] {
color: var(--kz-time-on-ink);
box-shadow: inset 0 0 0 1px transparent;
}
.kodza-time__slot[aria-pressed="true"]:hover {
background: none;
}
.kodza-time__slot:disabled {
color: var(--kz-time-muted);
box-shadow: inset 0 0 0 1px var(--kz-time-soft);
text-decoration: line-through;
cursor: not-allowed;
}
.kodza-time__slot:disabled:hover {
background: none;
}
.kodza-time__slot:focus-visible,
.kodza-time__done:focus-visible {
outline: 2px solid var(--kz-time-fg);
outline-offset: 2px;
}
/* The ink mark: it travels to the slot you pick */
.kodza-time__mark {
position: absolute;
top: 0;
left: 0;
width: var(--kz-mark-w, 0);
height: var(--kz-mark-h, 0);
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-time-ink);
transform: translate(var(--kz-mark-x, 0), var(--kz-mark-y, 0));
transition:
transform calc(0.4s * var(--kz-motion-scale, 1)) var(--kz-time-ease),
opacity calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-time__mark.is-hidden {
opacity: 0;
transform: translate(var(--kz-mark-x, 0), var(--kz-mark-y, 0)) scale(0.4);
}
@media (prefers-reduced-motion: reduce) {
.kodza-time__native,
.kodza-time__trigger,
.kodza-time__pop,
.kodza-time__slot,
.kodza-time__mark {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-time {
--kz-time-bg: #161616;
--kz-time-fg: #f2f2f0;
--kz-time-muted: rgba(242, 242, 240, 0.62);
--kz-time-line: rgba(255, 255, 255, 0.14);
--kz-time-line-hover: rgba(255, 255, 255, 0.3);
--kz-time-soft: rgba(255, 255, 255, 0.08);
--kz-time-ink: #f2f2f0;
--kz-time-on-ink: #0a0a0a;
--kz-time-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.8);
}
*/
/* kodza-guard 06594cb6bd2f — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-time__native):not(#_) {
outline: revert;
position: revert;
}
:where(.kodza-time__trigger):not(#_) {
outline: revert;
}
:where(.kodza-time__done):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-time__slot):not(#_) {
outline: revert;
text-decoration: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-time:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-time__label:not(#_),
.kodza-time__value:not(#_),
.kodza-time__line:not(#_),
.kodza-time__part:not(#_),
.kodza-time__wheel:not(#_),
.kodza-time__band:not(#_),
.kodza-time__col:not(#_),
.kodza-time__item:not(#_),
.kodza-time__lens:not(#_),
.kodza-time__reel:not(#_),
.kodza-time__ghost:not(#_),
.kodza-time__sep:not(#_),
.kodza-time__foot:not(#_),
.kodza-time__slots:not(#_),
.kodza-time__mark:not(#_),
.kodza-time__part span:where(:not([class*="kodza-"])):not(#_),
.kodza-time__value span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-time__native:not(#_) {
text-decoration: revert;
list-style: revert;
float: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-time__trigger:not(#_) {
text-decoration: revert;
list-style: revert;
float: revert;
position: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-time__icon:not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-time__list:not(#_) {
box-sizing: revert;
box-shadow: revert;
outline-offset: revert;
}
.kodza-time__done:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
line-height: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-time__count:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-time__slot:not(#_) {
list-style: revert;
box-sizing: revert;
float: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
min-height: revert;
}
.kodza-time__icon:not(#_):where(:not([hidden])),
.kodza-time__count:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-time__icon:not(#_):where([hidden]),
.kodza-time__count:not(#_):where([hidden]) {
display: none;
}
.kodza-time__list:not(#_)::before,
.kodza-time__list:not(#_)::after {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-time:not(#_) {
position: relative;
display: block;
transition: z-index 0s linear calc(0.4s * var(--kz-motion-scale, 1));
color: var(--kz-time-fg);
}
.kodza-time:not(#_) {
width: min(100%, var(--kz-time-width));
}
.kodza-time.is-open:not(#_) {
transition-delay: 0s;
}
.kodza-time__native:not(#_),
.kodza-time__trigger:not(#_) {
display: flex;
align-items: flex-end;
width: 100%;
height: var(--kz-time-height);
margin: 0;
padding: 0 44px 9px var(--kz-time-pad);
border: 0;
border-radius: var(--kz-time-radius);
background: var(--kz-time-bg);
box-shadow: inset 0 0 0 1px var(--kz-time-line);
box-sizing: border-box;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
text-align: left;
cursor: pointer;
transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}
.kodza-time__native:hover:not(#_),
.kodza-time__trigger:hover:not(#_) {
box-shadow: inset 0 0 0 1px var(--kz-time-line-hover);
}
.kodza-time__native:focus-visible:not(#_),
.kodza-time__trigger:focus-visible:not(#_),
.kodza-time.is-open .kodza-time__trigger:not(#_) {
outline: none;
box-shadow: inset 0 0 0 1.5px var(--kz-time-fg), 0 0 0 4px var(--kz-time-soft);
}
.kodza-time.is-ready .kodza-time__native:not(#_) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
clip-path: inset(50%);
}
.kodza-time__icon:not(#_) {
position: absolute;
}
.kodza-time__list:not(#_) {
outline: none;
}
.kodza-time__done:not(#_) {
display: grid;
place-items: center;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: none;
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, revert);
text-transform: var(--kz-font-label-case, revert);
cursor: pointer;
transition: background-color calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-time__done:hover:not(#_) {
background: var(--kz-time-soft);
}
.kodza-time__count:not(#_) {
margin: 2px 4px 10px;
color: var(--kz-time-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 11px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
}
.kodza-time__slot:not(#_) {
position: relative;
z-index: 1;
display: grid;
place-items: center;
height: 36px;
margin: 0;
padding: 0 6px;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: none;
box-shadow: inset 0 0 0 1px var(--kz-time-line);
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 500);
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, revert);
text-transform: var(--kz-font-label-case, revert);
white-space: nowrap;
cursor: pointer;
transition: color calc(0.2s * var(--kz-motion-scale, 1)) ease calc(0.1s * var(--kz-motion-scale, 1)),
background-color calc(0.2s * var(--kz-motion-scale, 1)) ease,
box-shadow calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-time__slot:hover:not(#_) {
background: var(--kz-time-soft);
}
.kodza-time__slot[aria-pressed="true"]:not(#_) {
color: var(--kz-time-on-ink);
box-shadow: inset 0 0 0 1px transparent;
}
.kodza-time__slot[aria-pressed="true"]:hover:not(#_) {
background: none;
}
.kodza-time__slot:disabled:not(#_) {
color: var(--kz-time-muted);
box-shadow: inset 0 0 0 1px var(--kz-time-soft);
text-decoration: line-through;
cursor: not-allowed;
}
.kodza-time__slot:disabled:hover:not(#_) {
background: none;
}
.kodza-time__slot:focus-visible:not(#_),
.kodza-time__done:focus-visible:not(#_) {
outline: 2px solid var(--kz-time-fg);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-time__native:not(#_),
.kodza-time__trigger:not(#_),
.kodza-time__slot:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Time Picker
// Builds a field and a panel over the real <input type="time"> and keeps it in step (firing
// "input" and "change" on it). 12 or 24 hours as data-locale (or the page language) says,
// data-clock="12" or "24" to force it; min, max and step are read on the input. The panel is a
// wheel: hours, minutes (and AM / PM), each column a listbox that scrolls and snaps. Arrows turn
// a column by one, Page Up / Down by five, Home / End to the ends, Left / Right to the next
// column, Enter takes it. Once the wheel stops, the time is taken on its own, and a time out of
// min / max glides back to the nearest one allowed. With .kodza-time--slots, the panel lists the
// slots of a day instead (min to max, or DAY_START to DAY_END, every step, or SLOT_STEP), and the
// times in data-taken="10:00 11:30" are struck out. Escape closes and gives the focus back.
// The words and timings are below; each word can be set per field with data-text-<name>
// (data-text-empty="…"), {free}, {total} and {time} are filled in.
(() => {
const SELECTOR = '.kodza-time';
const WORDS = {
empty: 'Pick a time', // the field, with no time yet
choose: 'choose a time', // the panel, read after the label
hours: 'Hours',
minutes: 'Minutes',
period: 'AM or PM',
done: 'Done',
free: '{free} of {total} free', // above the slots
taken: '{time}, taken', // a taken slot, read out
};
const ROLL = 420; // ms, a part of the field rolling to its new value
const GLIDE = 360; // ms, the wheel gliding to a time (a click, the keys, back into min / max)
const SETTLE = 160; // ms of stillness before the wheel takes its time (a wait, not a motion)
const CLOSE_AFTER = 320; // ms, the mark landing on a slot before the panel closes
const PAGE = 5; // lines per Page Up / Page Down
const WHEEL_STEP = 300; // s, the wheel's minutes when the input has no step (every 5 minutes)
const SLOT_STEP = 1800; // s, the slots when the input has no step (every 30 minutes)
const DAY_START = '09:00'; // the first slot of a day, when the input has no min
const DAY_END = '17:00'; // and the last one, when it has no max
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
// script times it, and 0 moves nothing, like reduced motion
const paceOf = (el) => {
const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
return pace >= 0 ? pace : 1;
};
const easeOut = (x) => 1 - (1 - x) ** 4;
const DAY = 1440; // minutes
// A time is counted in minutes from midnight
const toMinutes = (value) => {
const m = /^(\d{1,2}):(\d{2})/.exec(value || '');
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
return h < 24 && min < 60 ? h * 60 + min : null;
};
const toText = (t) => `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
const at = (t) => new Date(2000, 0, 1, Math.floor(t / 60), t % 60);
const fill = (words, values) => words.replace(/\{(\w+)\}/g, (all, key) => (key in values ? values[key] : all));
const icon = '<svg class="kodza-time__icon" viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="6.5"/><path d="M9 5.5V9l2.5 1.5"/></svg>';
let uid = 0;
// A part of the time rolls to its new face (up when the time goes forward, down when it goes
// back), and its box follows the new width
const swap = (box, next, dir, pace) => {
const from = box.offsetWidth;
const prev = box.lastElementChild;
box.append(next);
if (!prev) return;
if (reduceMotion || !pace) return prev.remove();
const to = next.offsetWidth;
const timing = { duration: ROLL * pace, easing: ease };
const fit = from !== to ? box.animate([{ width: `${from}px` }, { width: `${to}px` }], { ...timing, fill: 'forwards' }) : null;
// One keyframe: a face sent off while it was still arriving leaves from where it is
prev.animate({ transform: `translateY(${dir * -110}%)` }, { ...timing, fill: 'forwards' }).finished.then(() => {
prev.remove();
fit?.cancel();
}, () => {});
next.animate([{ transform: `translateY(${dir * 110}%)` }, { transform: 'none' }], timing);
};
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
// The pace is read once the field is built (end of mount): read before, the style it computes
// would turn the field's first state into a transition
let pace = 1;
let still = reduceMotion; // the still path: no motion asked, by the visitor or the site
const native = root.querySelector('.kodza-time__native');
const label = root.querySelector('.kodza-time__label');
if (!native) return;
const id = `kodza-time-${++uid}`;
const word = (key) => root.dataset[`text${key[0].toUpperCase()}${key.slice(1)}`] || WORDS[key];
const locale = root.dataset.locale || document.documentElement.lang || undefined;
const slots = root.classList.contains('kodza-time--slots');
// 12 or 24 hours: data-clock, or what the locale uses
const h12 = root.dataset.clock ? root.dataset.clock === '12' : (() => {
const options = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions();
return options.hourCycle ? options.hourCycle === 'h11' || options.hourCycle === 'h12' : !!options.hour12;
})();
const shown = new Intl.DateTimeFormat(locale, h12 ? { hour: 'numeric', minute: '2-digit', hourCycle: 'h12' } : { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
const min = toMinutes(native.min);
const max = toMinutes(native.max);
const wraps = min !== null && max !== null && min > max; // a range past midnight (22:00 to 02:00)
const allowed = (t) => (wraps ? t >= min || t <= max : (min === null || t >= min) && (max === null || t <= max));
const step = Math.max(1, Math.round((parseFloat(native.step) || (slots ? SLOT_STEP : WHEEL_STEP)) / 60));
let current = toMinutes(native.value);
// The field
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'kodza-time__trigger';
trigger.setAttribute('aria-haspopup', 'dialog');
trigger.setAttribute('aria-expanded', 'false');
if (label) {
label.id = `${id}-label`;
trigger.setAttribute('aria-labelledby', `${id}-label ${id}-value`);
}
const value = Object.assign(document.createElement('span'), { className: 'kodza-time__value', id: `${id}-value` });
trigger.append(value);
trigger.insertAdjacentHTML('beforeend', icon);
// The panel
const pop = document.createElement('div');
pop.className = 'kodza-time__pop';
pop.setAttribute('role', 'dialog');
pop.setAttribute('aria-label', label ? `${label.textContent}: ${word('choose')}` : word('choose').replace(/^./, (c) => c.toUpperCase()));
native.after(trigger, pop);
native.tabIndex = -1;
native.setAttribute('aria-hidden', 'true');
// The time in the field, part by part (hours, the colon, minutes, AM / PM), so each part can
// roll on its own
let shownTime;
const line = (t) => {
if (t === null) {
const empty = Object.assign(document.createElement('span'), { className: 'is-empty', textContent: word('empty') });
empty.dataset.shape = 'empty';
return empty;
}
const parts = shown.formatToParts(at(t));
const box = Object.assign(document.createElement('span'), { className: 'kodza-time__line' });
box.dataset.shape = parts.map((p) => p.type).join(' ');
parts.forEach((p) => {
const part = Object.assign(document.createElement('span'), { className: 'kodza-time__part' });
part.append(Object.assign(document.createElement('span'), { textContent: p.value }));
box.append(part);
});
return box;
};
const show = (t, animate) => {
if (t === shownTime) return;
const dir = t !== null && shownTime != null && t < shownTime ? -1 : 1;
shownTime = t;
const next = line(t);
const old = value.lastElementChild;
if (!animate || !old) return value.replaceChildren(next);
// A time after a time: only the parts that change roll
if (old.classList.contains('kodza-time__line') && old.dataset.shape === next.dataset.shape) {
[...next.children].forEach((part, i) => {
const face = part.firstElementChild;
if (old.children[i].lastElementChild.textContent !== face.textContent) swap(old.children[i], face, dir, pace);
});
return;
}
swap(value, next, dir, pace);
};
// Take a time: the real input and its events, then the field
let busy = false;
const commit = (t) => {
if (t !== current) {
current = t;
native.value = t === null ? '' : toText(t);
busy = true;
try {
native.dispatchEvent(new Event('input', { bubbles: true }));
native.dispatchEvent(new Event('change', { bubbles: true }));
} finally {
busy = false;
}
}
show(t, true);
};
const isOpen = () => root.classList.contains('is-open');
let timer = 0;
let frame = 0;
let prepare; // sets the panel for the time, gives what takes the focus
let flush = () => {}; // takes what the panel holds, as it closes
let follow = () => {}; // the time was changed from outside
if (!slots) {
// The wheel. Its times: every step from min (or midnight), within min / max
const base = min ?? 0;
const grid = [];
const faces = new Set();
for (let t = base % step; t < DAY; t += step) {
faces.add(t % 60);
if (allowed(t)) grid.push(t);
}
const valid = new Set(grid);
const minutes = [...faces].sort((a, b) => a - b);
const gap = (a, b) => {
const d = Math.abs(a - b);
return wraps ? Math.min(d, DAY - d) : d;
};
const nearest = (t) => (valid.has(t) || !grid.length ? t : grid.reduce((best, g) => (gap(g, t) < gap(best, t) ? g : best)));
const now = () => {
const d = new Date();
return d.getHours() * 60 + d.getMinutes();
};
// The columns
const two = new Intl.NumberFormat(locale, { minimumIntegerDigits: 2, useGrouping: false });
const one = new Intl.NumberFormat(locale, { useGrouping: false });
const sample = shown.formatToParts(at(0));
const hourAt = sample.findIndex((p) => p.type === 'hour');
const sep = (sample[hourAt + 1]?.type === 'literal' && sample[hourAt + 1].value.trim()) || ':';
const column = (name, labels) => {
const col = Object.assign(document.createElement('div'), { className: 'kodza-time__col' });
const list = Object.assign(document.createElement('div'), { className: 'kodza-time__list', tabIndex: 0 });
list.setAttribute('role', 'listbox');
list.setAttribute('aria-label', word(name));
const items = labels.map((text, i) => {
const item = Object.assign(document.createElement('div'), { className: 'kodza-time__item', textContent: text, id: `${id}-${name}-${i}` });
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', 'false');
item.dataset.index = i;
list.append(item);
return item;
});
// The lens: a white copy of the column, cut to the band, moved with the scroll
const lens = Object.assign(document.createElement('div'), { className: 'kodza-time__lens' });
const reel = Object.assign(document.createElement('div'), { className: 'kodza-time__reel' });
lens.setAttribute('aria-hidden', 'true');
const ghosts = labels.map((text) => reel.appendChild(Object.assign(document.createElement('span'), { className: 'kodza-time__ghost', textContent: text })));
lens.append(reel);
col.append(list, lens);
return { name, col, list, items, reel, ghosts, index: -1, to: 0 };
};
const hours = column('hours', Array.from({ length: h12 ? 12 : 24 }, (_, i) => (h12 ? one.format(i || 12) : two.format(i))));
const mins = column('minutes', minutes.map((m) => two.format(m)));
const period = h12 ? column('period', [0, 720].map((t) => shown.formatToParts(at(t)).find((p) => p.type === 'dayPeriod')?.value || (t ? 'PM' : 'AM'))) : null;
// AM / PM goes first where the locale writes it first
const periodFirst = period && sample.findIndex((p) => p.type === 'dayPeriod') < hourAt;
const cols = (periodFirst ? [period, hours, mins] : [hours, mins, period]).filter(Boolean);
const wheel = Object.assign(document.createElement('div'), { className: 'kodza-time__wheel' });
const band = Object.assign(document.createElement('span'), { className: 'kodza-time__band' });
const colon = Object.assign(document.createElement('span'), { className: 'kodza-time__sep', textContent: sep });
band.setAttribute('aria-hidden', 'true');
colon.setAttribute('aria-hidden', 'true');
wheel.append(band, ...cols.map((c) => c.col));
hours.col.after(colon);
const foot = Object.assign(document.createElement('div'), { className: 'kodza-time__foot' });
const doneButton = Object.assign(document.createElement('button'), { type: 'button', className: 'kodza-time__done', textContent: word('done') });
foot.append(doneButton);
pop.append(wheel, foot);
// Where a line sits when it is on the band, and the line on the band now
const topOf = (c, i) => c.items[i].offsetTop - (c.list.clientHeight - c.items[i].offsetHeight) / 2;
const indexAt = (c) => Math.max(0, Math.min(c.items.length - 1, Math.round((c.list.scrollTop - topOf(c, 0)) / c.items[0].offsetHeight)));
const point = (c, i) => {
if (c.index === i) return false;
c.items[c.index]?.setAttribute('aria-selected', 'false');
c.index = i;
c.items[i].setAttribute('aria-selected', 'true');
c.list.setAttribute('aria-activedescendant', c.items[i].id);
return true;
};
// The lens follows its column, in the same frame
const paint = (c) => (c.reel.style.transform = `translateY(${-c.list.scrollTop}px)`);
const hourOf = (i, p) => (h12 ? (i % 12) + 12 * p : i);
// The time the columns say: on the band now (index), or where they are going (to)
const timeAt = (pick) => hourOf(pick(hours), period ? pick(period) : 0) * 60 + minutes[pick(mins)];
const timeOf = () => timeAt((c) => c.index);
const target = () => timeAt((c) => c.to);
const indicesOf = (t) => {
const h = Math.floor(t / 60);
const lines = { hours: h12 ? h % 12 : h, minutes: Math.max(0, minutes.indexOf(t % 60)), period: Math.floor(h / 12) };
return cols.map((c) => lines[c.name]);
};
// What min / max leave out stays on the wheel, faded, and the wheel glides back off it
const off = (c, i, yes) => {
c.items[i].classList.toggle('is-off', yes);
c.ghosts[i].classList.toggle('is-off', yes);
if (yes) c.items[i].setAttribute('aria-disabled', 'true');
else c.items[i].removeAttribute('aria-disabled');
};
const opens = (h) => minutes.some((m) => valid.has(h * 60 + m));
const fence = () => {
const p = period ? period.index : 0;
hours.items.forEach((_, i) => off(hours, i, !opens(hourOf(i, p))));
mins.items.forEach((_, i) => off(mins, i, !valid.has(hourOf(hours.index, p) * 60 + minutes[i])));
period?.items.forEach((_, i) => off(period, i, !hours.items.some((__, j) => opens(hourOf(j, i)))));
};
const place = (t) => {
indicesOf(t).forEach((i, n) => {
const c = cols[n];
point(c, i);
c.to = i;
c.list.scrollTop = topOf(c, i);
paint(c);
});
fence();
};
let gliding = false;
let dirty = false; // the wheel moved since its time was last taken
let held = false; // a finger is on the wheel
const stopGlide = () => {
cancelAnimationFrame(frame);
if (!gliding) return;
gliding = false;
cols.forEach((c) => c.list.classList.remove('is-gliding'));
};
// Sent to a time, the columns glide there together, snapping off on the way
const glideTo = (targets) => {
stopGlide();
clearTimeout(timer);
dirty = true;
const runs = [];
cols.forEach((c, n) => {
c.to = targets[n];
const from = c.list.scrollTop;
const to = topOf(c, targets[n]);
if (Math.abs(to - from) > 0.5) runs.push({ c, from, to });
});
const end = () => {
gliding = false;
runs.forEach(({ c, to }) => {
c.list.scrollTop = to;
c.list.classList.remove('is-gliding');
paint(c);
});
cols.forEach((c) => point(c, c.to));
fence();
settle(true);
};
if (!runs.length || still) return end();
gliding = true;
runs.forEach(({ c }) => c.list.classList.add('is-gliding'));
const start = performance.now();
const duration = GLIDE * pace;
const tick = (time) => {
const x = Math.min(1, (time - start) / duration);
runs.forEach(({ c, from, to }) => {
c.list.scrollTop = from + (to - from) * easeOut(x);
paint(c);
});
if (x < 1) frame = requestAnimationFrame(tick);
else end();
};
frame = requestAnimationFrame(tick);
};
// The wheel has stopped: it takes the nearest time allowed, gliding there first when it is
// not on it (out of min / max, or left between two lines)
const settle = (afterGlide = false) => {
clearTimeout(timer);
if (gliding || held || !isOpen()) return;
const goal = nearest(timeOf());
const targets = indicesOf(goal);
const away = cols.some((c, n) => c.index !== targets[n] || (!afterGlide && Math.abs(c.list.scrollTop - topOf(c, targets[n])) > 1));
if (away) return glideTo(targets);
if (!dirty) return;
dirty = false;
commit(goal);
};
const arm = () => {
clearTimeout(timer);
timer = setTimeout(() => !signal.aborted && settle(), SETTLE);
};
const moved = (c) => {
const i = indexAt(c);
if (point(c, i)) fence();
if (gliding) return;
if (c.to !== i) {
c.to = i;
dirty = true;
}
if (dirty) arm();
};
// One column sent to a line: the wheel glides to the nearest time allowed from there
const go = (c, i) => glideTo(indicesOf(nearest(timeAt((k) => (k === c ? i : k.to)))));
// The first line that is not faded, from i on that way, then back the other way
const reach = (c, i, dir) => {
const last = c.items.length - 1;
const from = Math.max(0, Math.min(last, i));
for (let k = from; k >= 0 && k <= last; k += dir) if (!c.items[k].classList.contains('is-off')) return k;
for (let k = from - dir; k >= 0 && k <= last; k -= dir) if (!c.items[k].classList.contains('is-off')) return k;
return c.to;
};
const done = () => {
const t = nearest(target());
stopGlide();
clearTimeout(timer);
dirty = false;
commit(t);
close();
};
prepare = () => {
stopGlide();
dirty = false;
place(nearest(current ?? now()));
return cols[0].list;
};
flush = () => {
clearTimeout(timer);
if (!dirty && !gliding) return;
const t = nearest(target());
stopGlide();
dirty = false;
commit(t);
};
follow = () => {
if (!isOpen()) return;
stopGlide();
dirty = false;
place(nearest(current ?? now()));
};
cols.forEach((c, n) => {
const { list } = c;
list.addEventListener('scroll', () => {
paint(c);
moved(c);
}, { passive: true, signal });
list.addEventListener('scrollend', () => settle(), { signal });
// A hand on the wheel stops a glide where it is
list.addEventListener('wheel', stopGlide, { passive: true, signal });
list.addEventListener('pointerdown', stopGlide, { signal });
list.addEventListener('touchstart', () => {
held = true;
stopGlide();
}, { passive: true, signal });
// A click on a line sends the wheel there, a click on the line on the band takes it
list.addEventListener('click', (event) => {
const item = event.target.closest('.kodza-time__item');
if (!item || item.classList.contains('is-off')) return;
const i = Number(item.dataset.index);
if (i === c.index && !gliding) return done();
go(c, i);
}, { signal });
list.addEventListener('keydown', (event) => {
const moves = {
ArrowUp: [c.to - 1, -1],
ArrowDown: [c.to + 1, 1],
PageUp: [c.to - PAGE, -1],
PageDown: [c.to + PAGE, 1],
Home: [0, 1],
End: [c.items.length - 1, -1],
};
if (moves[event.key]) {
event.preventDefault();
go(c, reach(c, ...moves[event.key]));
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
cols[n + (event.key === 'ArrowLeft' ? -1 : 1)]?.list.focus({ preventScroll: true });
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
done();
}
}, { signal });
});
const release = () => {
if (!held) return;
held = false;
if (dirty) arm();
};
document.addEventListener('touchend', release, { signal });
document.addEventListener('touchcancel', release, { signal });
doneButton.addEventListener('click', done, { signal });
} else {
// The slots of a day: every step from min to max (or DAY_START to DAY_END)
const start = min ?? toMinutes(DAY_START);
let end = max ?? toMinutes(DAY_END);
if (end < start) end += DAY;
const taken = new Set((root.dataset.taken || '').split(/[\s,]+/).map(toMinutes).filter((t) => t !== null));
const count = Object.assign(document.createElement('p'), { className: 'kodza-time__count' });
const list = Object.assign(document.createElement('div'), { className: 'kodza-time__slots' });
list.setAttribute('role', 'group');
if (label) list.setAttribute('aria-labelledby', label.id);
const mark = Object.assign(document.createElement('span'), { className: 'kodza-time__mark is-hidden' });
mark.setAttribute('aria-hidden', 'true');
list.append(mark);
const buttons = [];
for (let t = start; t <= end; t += step) {
const time = t % DAY;
const b = Object.assign(document.createElement('button'), { type: 'button', className: 'kodza-time__slot', textContent: shown.format(at(time)), tabIndex: -1 });
b.dataset.time = toText(time);
if (taken.has(time)) {
b.disabled = true;
b.setAttribute('aria-label', fill(word('taken'), { time: b.textContent }));
} else b.setAttribute('aria-pressed', 'false');
list.append(b);
buttons.push(b);
}
const free = buttons.filter((b) => !b.disabled);
count.textContent = fill(word('free'), { free: free.length, total: buttons.length });
pop.append(count, list);
// The ink mark under the chosen slot: it travels from one to the next, and grows in on the first
const chosenSlot = () => (current === null ? null : free.find((b) => b.dataset.time === toText(current)) || null);
const placeMark = (animate) => {
const chosen = chosenSlot();
free.forEach((b) => b.setAttribute('aria-pressed', String(b === chosen)));
if (!chosen) return mark.classList.add('is-hidden');
const jump = !animate || mark.classList.contains('is-hidden');
if (jump) mark.style.transition = 'none';
mark.style.setProperty('--kz-mark-x', `${chosen.offsetLeft}px`);
mark.style.setProperty('--kz-mark-y', `${chosen.offsetTop}px`);
mark.style.setProperty('--kz-mark-w', `${chosen.offsetWidth}px`);
mark.style.setProperty('--kz-mark-h', `${chosen.offsetHeight}px`);
if (!animate) mark.classList.remove('is-hidden');
if (jump) {
void mark.offsetWidth;
mark.style.transition = '';
}
mark.classList.remove('is-hidden');
};
// One slot takes the Tab key, the arrows move it; the box scrolls itself (the page never moves)
let active = null;
const rove = (b) => {
if (active) active.tabIndex = -1;
active = b;
if (b) b.tabIndex = 0;
};
const reveal = (b) => {
const top = b.offsetTop - 6;
const bottom = b.offsetTop + b.offsetHeight + 6;
if (top < list.scrollTop) list.scrollTop = top;
else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
};
prepare = () => {
placeMark(false);
const chosen = chosenSlot() || free[0] || null;
rove(chosen);
if (chosen) list.scrollTop = chosen.offsetTop - (list.clientHeight - chosen.offsetHeight) / 2;
return chosen;
};
follow = () => placeMark(isOpen());
list.addEventListener('click', (event) => {
const b = event.target.closest('.kodza-time__slot');
if (!b || b.disabled) return;
rove(b);
commit(toMinutes(b.dataset.time));
placeMark(true);
// Let the mark land, then close
clearTimeout(timer);
timer = setTimeout(() => !signal.aborted && close(), still ? 0 : CLOSE_AFTER * pace);
}, { signal });
list.addEventListener('keydown', (event) => {
const b = event.target.closest('.kodza-time__slot');
if (!b) return;
const i = free.indexOf(b);
let next;
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') next = free[i + (event.key === 'ArrowLeft' ? -1 : 1)];
else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
// The free slot of the nearest row that way, closest to the same column
const down = event.key === 'ArrowDown';
const beyond = free.filter((x) => (down ? x.offsetTop > b.offsetTop : x.offsetTop < b.offsetTop));
const row = Math.min(...beyond.map((x) => Math.abs(x.offsetTop - b.offsetTop)));
next = beyond
.filter((x) => Math.abs(x.offsetTop - b.offsetTop) === row)
.sort((x, y) => Math.abs(x.offsetLeft - b.offsetLeft) - Math.abs(y.offsetLeft - b.offsetLeft))[0];
} else if (event.key === 'Home') next = free[0];
else if (event.key === 'End') next = free[free.length - 1];
else return;
event.preventDefault();
if (!next) return;
rove(next);
next.focus({ preventScroll: true });
reveal(next);
}, { signal });
}
const open = () => {
if (isOpen()) return;
const focusTarget = prepare();
// Not enough room below: the panel opens upwards. A closed panel that changes side gets
// there at once, so it always unrolls from the field
const box = trigger.getBoundingClientRect();
const need = pop.offsetHeight + 16;
const above = box.bottom + need > innerHeight && box.top > need;
if (above !== root.classList.contains('is-above')) {
pop.style.transition = 'none';
root.classList.toggle('is-above', above);
void pop.offsetWidth;
pop.style.transition = '';
}
root.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
requestAnimationFrame(() => isOpen() && focusTarget?.focus({ preventScroll: true }));
};
const close = (refocus = true) => {
if (!isOpen()) return;
flush();
clearTimeout(timer);
root.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
if (refocus) trigger.focus({ preventScroll: true });
};
// The input changed by the page (a script that sets .value then fires "change", a form reset)
const sync = () => {
if (busy) return;
current = toMinutes(native.value);
show(current, true);
follow();
};
trigger.addEventListener('click', () => (isOpen() ? close() : open()), { signal });
root.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !isOpen()) return;
event.preventDefault();
close();
}, { signal });
// A click on the label text opens the panel instead of reaching the hidden input
root.addEventListener('click', (event) => {
if (trigger.contains(event.target) || pop.contains(event.target)) return;
event.preventDefault();
trigger.focus();
}, { signal });
pop.addEventListener('click', (event) => event.preventDefault(), { signal });
document.addEventListener('pointerdown', (event) => !root.contains(event.target) && close(false), { signal });
// Escape closes it even if the focus wandered off to the page
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && isOpen() && !root.contains(document.activeElement)) close();
}, { signal });
root.addEventListener('focusout', (event) => {
if (isOpen() && event.relatedTarget && !root.contains(event.relatedTarget)) close(false);
}, { signal });
native.addEventListener('change', sync, { signal });
native.form?.addEventListener('reset', () => setTimeout(() => !signal.aborted && sync()), { signal });
signal.addEventListener('abort', () => {
clearTimeout(timer);
cancelAnimationFrame(frame);
});
show(current, false);
pace = paceOf(root);
still = reduceMotion || !pace;
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it. Two
// components on one root (a reveal on a stat card) both start, and stop together.
const KODZA = Symbol.for('kodza');
const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
// Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
// running on it, a root is a copy of a started one (a cloned slide): it is left as it is
if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
// Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
// it has hydrated it. Any earlier start would change markup React has not taken over yet
const owner = root.closest('[data-kodza-manual]');
if (owner && !(scope && owner.contains(scope))) return;
const controller = new AbortController();
// Another Kodza component already running on this root: its markup as written is kept, and the
// two stop together
const before = root[KODZA];
const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
if (before) before.above = saved;
instances.set(root, { controller, saved, before });
root[KODZA] = saved;
root[RUNNING] = true;
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.saved.above?.stop(); // a Kodza component started after it on the same root stops first
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[RUNNING];
// Started on top of another one: that one stops next, and puts the markup back
if (instance.before) {
delete instance.before.above;
root[KODZA] = instance.before;
return instance.before.stop();
}
delete root[KODZA];
put(root, instance.saved);
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Time Picker, for React. From https://kodza.dev/components/time-picker/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: time-picker.css and time-picker.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './time-picker.css';
type Variant = "slots";
export type TimePickerProps = Omit<ComponentPropsWithoutRef<'label'>, "children" | "variant" | "vars" | "locale" | "taken"> & {
/** Modifier class(es): .kodza-time--slots */
variant?: Variant | Variant[];
/** data-locale on the root (default: en-GB) */
locale?: "en-GB" | "en-US";
/** data-taken on the root */
taken?: "10:00 11:30 12:30";
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-time-bg"?: string | number;
/** default: #0a0a0a */
"--kz-time-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-time-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.14) */
"--kz-time-line"?: string | number;
/** default: rgba(10, 10, 10, 0.3) */
"--kz-time-line-hover"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-time-soft"?: string | number;
/** default: #0a0a0a */
"--kz-time-ink"?: string | number;
/** default: #ffffff */
"--kz-time-on-ink"?: string | number;
/** default: 0 18px 40px -16px rgba(10, 10, 10, 0.3) */
"--kz-time-shadow"?: string | number;
/** default: 56px */
"--kz-time-height"?: string | number;
/** default: calc(12px * var(--kz-radius-scale, 1)) */
"--kz-time-radius"?: string | number;
/** default: 16px */
"--kz-time-pad"?: string | number;
/** default: 36px */
"--kz-time-row"?: string | number;
/** default: 64px */
"--kz-time-col"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-time-ease"?: string | number;
/** default: var(--kz-field-width, 320px) */
"--kz-time-width"?: string | number;
};
/** The text, plain: the script reads it and rebuilds the markup around it */
children?: string;
className?: string;
};
export function TimePicker({ variant, locale = "en-GB", taken, vars, children = "Deploy at", className, style, ...rest }: TimePickerProps) {
const ref = useRef<ComponentRef<'label'>>(null);
const classes = ["kodza-time", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-time--${v}`), className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, locale, taken, vars, children, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./time-picker.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<label key={key} {...rest} className={classes} data-kodza-manual data-locale={locale} data-taken={taken} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<span className="kodza-time__label">{children}</span>
{' '}
<input className="kodza-time__native" type="time" name="deploy" defaultValue="16:55" step="300" />
</label>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.