Add the M3 time picker

The dial and input time picker in a modal dialog, with the hour cycle
from the locale, steps, a min and max that may span midnight, and a
landscape layout, ported from Compose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 08:47:42 +02:00
co-authored by Claude Opus 5
parent 83ed671c4e
commit b8239dd215
12 changed files with 2092 additions and 1 deletions
+460
View File
@@ -0,0 +1,460 @@
/*
* M3's time picker (resources/views/components/timepicker.blade.php, resources/js/timepicker.js):
* the dial, the input variant, and the dialog around them.
*
* Sizes and roles are androidx Compose Material 3's (TimePickerTokens, TimeInputTokens, TimePicker.kt
* and TimePickerDialog.kt at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, Apache-2.0):
*
* [data-timepicker-surface] surface-container-high, extra-large corner, elevation 3; 24px in
* [data-timepicker-title] label-medium, on-surface-variant, 20px above the content
* [data-timepicker-picker] the dial variant
* [data-timepicker-display] the time selector: hour and minute boxes 96×80 (display-large,
* small corner; primary-container when selected, otherwise
* surface-container-highest), a 24px separator, and the period
* selector 52×80 beside them, 4px away
* [data-timepicker-dial] 256px, surface-container-highest; numbers in body-large at radius
* 101 (the 24-hour inner ring at 69), a primary selector — a 2px line,
* an 8px centre and a 48px handle — with the number under the handle
* in on-primary; 36px below the display, 24px above the actions
* [data-timepicker-inputs] the input variant: fields 96×72 in display-medium, "Hour" and
* "Minute" (or the error) below in body-small, the period 52×72
* [data-timepicker-actions] a 48px row: the mode toggle, then Cancel and OK
*
* The period selector is Compose's current one (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled,
* on by default): two toggle buttons 4px apart, round and surface-container-lowest when off, a 12px
* corner and primary-container with a bold label when on, not the outlined pair of the tokens.
*
* In a landscape window the dial variant lies on its side, as Compose's HorizontalTimePicker does
* whenever the screen is wider than it is tall: the display with the period selector (216×38, 16px
* under it) on the start side, the dial 36px after it, the actions under both; and the dial shrinks
* to 238 or 200px when the window is short (ClockFaceSizeModifier). The input variant always stands.
*
* The selector's angle is a registered property, so a single transition turns the line, carries the
* handle round and moves the clip that inks the number under it, on the default spatial spring. The
* numbers cross-fade between hours and minutes on the default effects spring.
*/
@property --timepicker-angle {
syntax: '<angle>';
inherits: true;
initial-value: 0deg;
}
@layer components {
[data-timepicker-field] .field-control {
cursor: pointer;
caret-color: transparent;
}
[data-timepicker-dialog] {
max-width: calc(100vw - 2rem);
max-height: calc(100dvh - 2rem);
}
[data-timepicker-surface] {
display: grid;
grid-template-columns: max-content;
justify-content: center;
padding: 1.5rem;
border-radius: var(--md-sys-shape-corner-xl);
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
color: var(--md-sys-color-on-surface);
}
[data-timepicker-title] {
padding-bottom: 1.25rem;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
[data-timepicker-picker] {
display: flex;
flex-direction: column;
align-items: center;
}
/* The mode is an attribute rather than x-show, which shows only on the next frame: focus moves
into the variant that was just switched to within the same tick. */
[data-timepicker-surface]:not([data-mode="input"]) :is([data-timepicker-typing], [data-timepicker-when="input"]),
[data-timepicker-surface][data-mode="input"] :is([data-timepicker-picker], [data-timepicker-when="dial"]) {
display: none;
}
[data-timepicker-when] {
display: contents;
}
/* ---- The time selector and the period selector ------------------------------------------ */
[data-timepicker-display] {
display: flex;
margin-bottom: 2.25rem;
}
[data-timepicker-numbers],
[data-timepicker-inputs] {
display: flex;
align-items: flex-start;
direction: ltr;
}
[data-timepicker-box] {
display: grid;
place-items: center;
width: 6rem;
height: 5rem;
border-radius: var(--md-sys-shape-corner-sm);
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-display-lg);
letter-spacing: var(--md-sys-typescale-display-lg-tracking);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition-property: background-color, color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-timepicker-box][aria-pressed="true"] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
[data-timepicker-separator] {
display: grid;
place-items: center;
width: 1.5rem;
height: 5rem;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-display-lg);
translate: 0 -0.25rem;
user-select: none;
}
[data-timepicker-period] {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 3.25rem;
height: 5rem;
margin-inline-start: 0.25rem;
}
[data-timepicker-period] > button {
flex: 1 1 0%;
min-width: 0;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-lowest);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
cursor: pointer;
transition-property: border-radius, background-color, color;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-timepicker-period] > button:active,
[data-timepicker-period] > button[aria-pressed="true"] {
border-radius: 0.75rem;
}
[data-timepicker-period] > button[aria-pressed="true"] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
font-weight: var(--md-ref-typeface-weight-bold);
}
[data-timepicker-period] > button:disabled {
cursor: default;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
/* ---- The dial ----------------------------------------------------------------------------- */
[data-timepicker-dial] {
--dial: 16rem;
--unit: calc(var(--dial) / 256);
--outer: calc(101 * var(--unit));
--inner: calc(69 * var(--unit));
--handle: calc(48 * var(--unit));
--reach: var(--outer);
position: relative;
flex: none;
width: var(--dial);
height: var(--dial);
margin-bottom: 1.5rem;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
cursor: pointer;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
outline: none;
direction: ltr;
transition: --timepicker-angle var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
[data-timepicker-dial]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/* A drag keeps the handle under the pointer; the spring only settles it on release. */
[data-timepicker-dial][data-dragging] {
transition: none;
}
[data-timepicker-dial][data-inner] {
--reach: var(--inner);
}
[data-timepicker-labels],
[data-timepicker-ink],
[data-timepicker-set] {
position: absolute;
inset: 0;
}
[data-timepicker-set] {
transition-property: opacity, visibility;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
}
[data-timepicker-dial]:not([data-cycle="24"]) [data-timepicker-set="hour24"],
[data-timepicker-dial][data-cycle="24"] [data-timepicker-set="hour12"] {
display: none;
}
[data-timepicker-dial][data-view="minute"] :is([data-timepicker-set="hour12"], [data-timepicker-set="hour24"]),
[data-timepicker-dial]:not([data-view="minute"]) [data-timepicker-set="minute"] {
opacity: 0;
visibility: hidden;
}
[data-timepicker-set] > span {
--ring: var(--outer);
position: absolute;
left: calc(50% + var(--x) * var(--ring));
top: calc(50% + var(--y) * var(--ring));
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
font-variant-numeric: tabular-nums;
}
[data-timepicker-set] > span[data-inner] {
--ring: var(--inner);
}
[data-timepicker-set] > span[data-disabled] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
/* The selector: the line from the centre to the handle's edge, the centre dot, the handle. */
[data-timepicker-selector] {
position: absolute;
inset: 0;
pointer-events: none;
}
[data-timepicker-track] {
position: absolute;
left: calc(50% - 1px);
bottom: 50%;
width: 2px;
height: calc(var(--reach) - var(--handle) / 2);
background-color: var(--md-sys-color-primary);
transform-origin: 50% 100%;
rotate: var(--timepicker-angle);
}
[data-timepicker-centre],
[data-timepicker-handle] {
position: absolute;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary);
}
[data-timepicker-centre] {
left: 50%;
top: 50%;
width: 0.5rem;
height: 0.5rem;
}
[data-timepicker-handle] {
left: calc(50% + sin(var(--timepicker-angle)) * var(--reach));
top: calc(50% - cos(var(--timepicker-angle)) * var(--reach));
width: var(--handle);
height: var(--handle);
}
/* The numbers again, in on-primary, cut to the handle: what Compose draws with BlendMode.DstOver. */
[data-timepicker-ink] {
color: var(--md-sys-color-on-primary);
pointer-events: none;
clip-path: circle(calc(var(--handle) / 2) at calc(50% + sin(var(--timepicker-angle)) * var(--reach)) calc(50% - cos(var(--timepicker-angle)) * var(--reach)));
}
[data-timepicker-ink] [data-timepicker-set] > span[data-disabled] {
color: inherit;
}
/* ---- The input variant -------------------------------------------------------------------- */
[data-timepicker-inputs] [data-timepicker-separator] {
height: 4.5rem;
}
[data-timepicker-inputs] [data-timepicker-period] {
height: 4.5rem;
}
[data-timepicker-column] {
width: 6rem;
}
[data-timepicker-input] {
display: block;
width: 6rem;
height: 4.5rem;
padding: 0;
border: 0;
border-radius: var(--md-sys-shape-corner-sm);
outline: none;
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
caret-color: var(--md-sys-color-primary);
font: var(--md-sys-typescale-display-md);
letter-spacing: var(--md-sys-typescale-display-md-tracking);
font-variant-numeric: tabular-nums;
text-align: center;
transition-property: background-color, color, box-shadow;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-timepicker-input]:focus {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
box-shadow: inset 0 0 0 2px var(--md-sys-color-primary);
}
[data-timepicker-input][aria-invalid="true"] {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-error);
caret-color: var(--md-sys-color-error);
box-shadow: inset 0 0 0 1px var(--md-sys-color-error);
}
[data-timepicker-input][aria-invalid="true"]:focus {
box-shadow: inset 0 0 0 2px var(--md-sys-color-error);
}
/* SupportingText: two lines' room, 7px under the field; the error in its place. */
[data-timepicker-support] {
min-height: 2rem;
padding-top: 0.4375rem;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-timepicker-support][data-error],
[data-timepicker-range-error] {
color: var(--md-sys-color-error);
}
[data-timepicker-range-error] {
max-width: 17rem;
padding-bottom: 0.5rem;
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-timepicker-actions] {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 3rem;
}
[data-timepicker-actions] > [data-timepicker-spacer] {
flex: 1 1 0%;
}
/* ---- Landscape ---------------------------------------------------------------------------- */
@media (orientation: landscape) and (min-width: 37rem) {
[data-timepicker-surface][data-mode="dial"] {
grid-template-columns: 13.5rem auto;
grid-template-areas:
"display dial"
"actions actions";
column-gap: 2.25rem;
padding: 1rem 1.5rem 0.5rem;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-title] {
grid-area: display;
align-self: start;
margin-top: 0.5rem;
padding: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-picker] {
display: contents;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] {
grid-area: display;
flex-direction: column;
align-self: center;
margin: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] [data-timepicker-period] {
flex-direction: row;
width: 13.5rem;
height: 2.375rem;
margin: 1rem 0 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
grid-area: dial;
margin: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-actions] {
grid-area: actions;
margin-top: 0.25rem;
}
}
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 22.75rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
--dial: 14.875rem;
}
}
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 21.625rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
--dial: 12.5rem;
}
}
}
+1
View File
@@ -26,6 +26,7 @@
@import './components/menu.css';
@import './components/selection.css';
@import './components/search.css';
@import './components/timepicker.css';
@import './components/tabs.css';
@import './components/app-bar.css';
@import './components/toolbar.css';