Plan step 36 review of d46b07c1. The landscape queries moved from rem
to px (560/364/346, equal at the default 16px) while every size they
make room for stayed in rem, so with a larger browser text size the
dial grew past a threshold that no longer moved with it. The sizes are
px now, as M3 gives them in dp, spacing the measurement tokens, the
selected period corner corner-md, and disabled the 38% and 12% state
tokens; the supporting text's two-line room stays in rem, being text.
The header's arithmetic is in px, and its note on targets says why the
period halves render no md-touch-target instead of claiming nothing
here reaches 48px.
The landscape browser test expected the display and the dial to share a
top edge, but the display is centred in the dial's 256px row, 61px
lower; it now checks the display sits inside that row with the dial
36px after it. Feature tests pin the classes and the three queries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
523 lines
20 KiB
CSS
523 lines
20 KiB
CSS
/*
|
||
* 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-md-timepicker-surface] surface-container-high, extra-large corner, elevation 3; 24px in
|
||
* [data-md-timepicker-title] label-medium, on-surface-variant, 20px above the content
|
||
* [data-md-timepicker-picker] the dial variant
|
||
* [data-md-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. On a 24-hour clock
|
||
* (data-md-cycle="24") there is no period selector and the boxes are
|
||
* 114px, TimeSelector24HVerticalContainerWidth.
|
||
* [data-md-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-md-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-md-timepicker-actions] a 48px row: the mode toggle, then Cancel and OK
|
||
*
|
||
* The period selector keeps Compose's current shape (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled,
|
||
* on by default): two buttons 4px apart, round and surface-container-lowest when off, a 12px corner
|
||
* when on, not the outlined pair of the tokens. The selected colour is M3's own tertiary-container
|
||
* with a bold label (PeriodSelectorSelectedContainerColor), which keeps AM/PM apart from the
|
||
* primary-container hour and minute boxes. They are radios, so the state is `aria-checked`.
|
||
*
|
||
* In a landscape window with no room for the upright dial the dial variant lies on its side, as
|
||
* Compose's HorizontalTimePicker does: 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 shorter still (ClockFaceSizeModifier). The input variant always stands.
|
||
*
|
||
* The switch is orientation and viewport *height*, never a width breakpoint: M3 says the picker
|
||
* "swaps orientation/variant based on device orientation and viewport height"
|
||
* (docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/Behaviour), and no
|
||
* window size class applies — so the media queries below name a viewport height and never a width.
|
||
* 560px is what the upright dial needs — 528px of surface (48 padding, 36 title, 116 display, 280
|
||
* dial, 48 actions) inside the dialog's `100dvh - 32px` — so a landscape phone lies the dial down
|
||
* and a landscape tablet or desktop, which has the height, leaves it standing. The queries and the
|
||
* sizes they make room for are both px (M3 gives the sizes in dp), so a larger text size moves
|
||
* neither.
|
||
*
|
||
* The hour and minute boxes and the period buttons render the foundation's `md-state-layer` and
|
||
* `md-focus-ring` (foundation/interaction.css) — each is its own interactive box with nothing drawn
|
||
* smaller inside it, so the classes need no refinement. The dial renders `md-focus-ring` alone (it
|
||
* draws no state layer of its own); nothing here copies its outline rule. Cancel, OK and the mode
|
||
* toggle are <x-button>, which draws from the classes already. `md-touch-target` is not rendered:
|
||
* the boxes, the dial and the inputs are well over 48px, and the period buttons are the two halves
|
||
* of M3's 52×80 (72 in the input variant, 216×38 lying down) selector, where a 48px target on each
|
||
* would reach over its neighbour.
|
||
*
|
||
* Disabled is on-surface at M3's 38% content and 12% container opacities (tokens/state.css).
|
||
*
|
||
* 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.
|
||
*/
|
||
|
||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||
|
||
@import './field.css';
|
||
@import './icon.css';
|
||
@import './button.css';
|
||
|
||
@layer material.components {
|
||
@property --timepicker-angle {
|
||
syntax: '<angle>';
|
||
inherits: true;
|
||
initial-value: 0deg;
|
||
}
|
||
|
||
[data-md-timepicker-field] [data-md-field-control] {
|
||
cursor: pointer;
|
||
caret-color: transparent;
|
||
}
|
||
|
||
/* The dialog: M3's spatial-fast pop, a 32%-scrim backdrop, no default border or padding. */
|
||
[data-md-timepicker-dialog] {
|
||
max-width: calc(100vw - var(--md-sys-measurement-space400));
|
||
max-height: calc(100dvh - var(--md-sys-measurement-space400));
|
||
margin: auto;
|
||
padding: 0;
|
||
overflow: visible;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--md-sys-color-on-surface);
|
||
opacity: 1;
|
||
scale: 1;
|
||
transition-property: opacity, scale;
|
||
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||
transition-timing-function: var(--md-sys-motion-spatial-fast);
|
||
}
|
||
|
||
@starting-style {
|
||
[data-md-timepicker-dialog] {
|
||
opacity: 0;
|
||
scale: 0.95;
|
||
}
|
||
}
|
||
|
||
[data-md-timepicker-dialog]::backdrop {
|
||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||
}
|
||
|
||
[data-md-timepicker-surface] {
|
||
display: grid;
|
||
grid-template-columns: max-content;
|
||
justify-content: center;
|
||
padding: var(--md-sys-measurement-space300);
|
||
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-md-timepicker-title] {
|
||
padding-bottom: 20px;
|
||
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-md-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-md-timepicker-surface]:not([data-md-mode='input']) :is([data-md-timepicker-typing], [data-md-timepicker-when='input']),
|
||
[data-md-timepicker-surface][data-md-mode='input'] :is([data-md-timepicker-picker], [data-md-timepicker-when='dial']) {
|
||
display: none;
|
||
}
|
||
|
||
[data-md-timepicker-when] {
|
||
display: contents;
|
||
}
|
||
|
||
/* ---- The time selector and the period selector ------------------------------------------ */
|
||
|
||
[data-md-timepicker-display] {
|
||
display: flex;
|
||
margin-bottom: 36px;
|
||
}
|
||
|
||
[data-md-timepicker-numbers],
|
||
[data-md-timepicker-inputs] {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
direction: ltr;
|
||
}
|
||
|
||
[data-md-timepicker-box] {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 96px;
|
||
height: 80px;
|
||
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-md-timepicker-box][aria-pressed='true'] {
|
||
background-color: var(--md-sys-color-primary-container);
|
||
color: var(--md-sys-color-on-primary-container);
|
||
}
|
||
|
||
/* A 24-hour clock has no period selector beside the boxes, and M3 widens them into the room
|
||
(TimePickerTokens.TimeSelector24HVerticalContainerWidth = 114dp). Landscape keeps 96px: the
|
||
display is a 216px column there. */
|
||
[data-md-timepicker-display][data-md-cycle='24'] [data-md-timepicker-box] {
|
||
width: 114px;
|
||
}
|
||
|
||
[data-md-timepicker-separator] {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 24px;
|
||
height: 80px;
|
||
color: var(--md-sys-color-on-surface);
|
||
font: var(--md-sys-typescale-display-lg);
|
||
translate: 0 -4px;
|
||
user-select: none;
|
||
}
|
||
|
||
[data-md-timepicker-period] {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--md-sys-measurement-space50);
|
||
width: 52px;
|
||
height: 80px;
|
||
margin-inline-start: var(--md-sys-measurement-space50);
|
||
}
|
||
|
||
[data-md-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-md-timepicker-period] > button:active,
|
||
[data-md-timepicker-period] > button[aria-checked='true'] {
|
||
border-radius: var(--md-sys-shape-corner-md);
|
||
}
|
||
|
||
/* Tertiary, not the primary the hour and minute boxes take: M3 gives the two selectors different
|
||
emphases (PeriodSelectorSelectedContainerColor = TertiaryContainer). */
|
||
[data-md-timepicker-period] > button[aria-checked='true'] {
|
||
background-color: var(--md-sys-color-tertiary-container);
|
||
color: var(--md-sys-color-on-tertiary-container);
|
||
font-weight: var(--md-ref-typeface-weight-bold);
|
||
}
|
||
|
||
[data-md-timepicker-period] > button:disabled {
|
||
cursor: default;
|
||
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
|
||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
|
||
}
|
||
|
||
/* ---- The dial ----------------------------------------------------------------------------- */
|
||
|
||
[data-md-timepicker-dial] {
|
||
--dial: 256px;
|
||
--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: var(--md-sys-measurement-space300);
|
||
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;
|
||
direction: ltr;
|
||
transition: --timepicker-angle var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
|
||
}
|
||
|
||
/* A drag keeps the handle under the pointer; the spring only settles it on release. */
|
||
[data-md-timepicker-dial][data-md-dragging] {
|
||
transition: none;
|
||
}
|
||
|
||
[data-md-timepicker-dial][data-md-inner] {
|
||
--reach: var(--inner);
|
||
}
|
||
|
||
[data-md-timepicker-labels],
|
||
[data-md-timepicker-ink],
|
||
[data-md-timepicker-set] {
|
||
position: absolute;
|
||
inset: 0;
|
||
}
|
||
|
||
[data-md-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-md-timepicker-dial]:not([data-md-cycle='24']) [data-md-timepicker-set='hour24'],
|
||
[data-md-timepicker-dial][data-md-cycle='24'] [data-md-timepicker-set='hour12'] {
|
||
display: none;
|
||
}
|
||
|
||
[data-md-timepicker-dial][data-md-view='minute'] :is([data-md-timepicker-set='hour12'], [data-md-timepicker-set='hour24']),
|
||
[data-md-timepicker-dial]:not([data-md-view='minute']) [data-md-timepicker-set='minute'] {
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
}
|
||
|
||
[data-md-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: 48px;
|
||
height: 48px;
|
||
translate: -50% -50%;
|
||
border-radius: var(--md-sys-shape-corner-full);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
[data-md-timepicker-set] > span[data-md-inner] {
|
||
--ring: var(--inner);
|
||
}
|
||
|
||
[data-md-timepicker-set] > span[data-md-disabled] {
|
||
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
|
||
}
|
||
|
||
/* The selector: the line from the centre to the handle's edge, the centre dot, the handle. */
|
||
[data-md-timepicker-selector] {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
[data-md-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-md-timepicker-centre],
|
||
[data-md-timepicker-handle] {
|
||
position: absolute;
|
||
translate: -50% -50%;
|
||
border-radius: var(--md-sys-shape-corner-full);
|
||
background-color: var(--md-sys-color-primary);
|
||
}
|
||
|
||
[data-md-timepicker-centre] {
|
||
left: 50%;
|
||
top: 50%;
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
[data-md-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-md-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-md-timepicker-ink] [data-md-timepicker-set] > span[data-md-disabled] {
|
||
color: inherit;
|
||
}
|
||
|
||
/* ---- The input variant -------------------------------------------------------------------- */
|
||
|
||
[data-md-timepicker-inputs] [data-md-timepicker-separator] {
|
||
height: 72px;
|
||
}
|
||
|
||
[data-md-timepicker-inputs] [data-md-timepicker-period] {
|
||
height: 72px;
|
||
}
|
||
|
||
[data-md-timepicker-column] {
|
||
width: 96px;
|
||
}
|
||
|
||
[data-md-timepicker-input] {
|
||
display: block;
|
||
width: 96px;
|
||
height: 72px;
|
||
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-md-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-md-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-md-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. The room is two
|
||
body-small lines (1rem each), so it stays in rem and grows with the text. */
|
||
[data-md-timepicker-support] {
|
||
min-height: 2rem;
|
||
padding-top: 7px;
|
||
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-md-timepicker-support][data-md-error],
|
||
[data-md-timepicker-range-error] {
|
||
color: var(--md-sys-color-error);
|
||
}
|
||
|
||
[data-md-timepicker-range-error] {
|
||
max-width: 272px;
|
||
padding-bottom: var(--md-sys-measurement-space100);
|
||
font: var(--md-sys-typescale-body-sm);
|
||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||
}
|
||
|
||
[data-md-timepicker-actions] {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--md-sys-measurement-space100);
|
||
min-height: 48px;
|
||
}
|
||
|
||
[data-md-timepicker-actions] > [data-md-timepicker-spacer] {
|
||
flex: 1 1 0%;
|
||
}
|
||
|
||
/* ---- Landscape ---------------------------------------------------------------------------- */
|
||
|
||
@media (orientation: landscape) and (height < 560px) {
|
||
[data-md-timepicker-surface][data-md-mode='dial'] {
|
||
grid-template-columns: 216px auto;
|
||
grid-template-areas:
|
||
'display dial'
|
||
'actions actions';
|
||
column-gap: 36px;
|
||
padding: var(--md-sys-measurement-space200) var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
|
||
}
|
||
|
||
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-title] {
|
||
grid-area: display;
|
||
align-self: start;
|
||
margin-top: var(--md-sys-measurement-space100);
|
||
padding: 0;
|
||
}
|
||
|
||
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-picker] {
|
||
display: contents;
|
||
}
|
||
|
||
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-display] {
|
||
grid-area: display;
|
||
flex-direction: column;
|
||
align-self: center;
|
||
margin: 0;
|
||
}
|
||
|
||
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-display][data-md-cycle='24'] [data-md-timepicker-box] {
|
||
width: 96px;
|
||
}
|
||
|
||
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-display] [data-md-timepicker-period] {
|
||
flex-direction: row;
|
||
width: 216px;
|
||
height: 38px;
|
||
margin: var(--md-sys-measurement-space200) 0 0;
|
||
}
|
||
|
||
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial] {
|
||
grid-area: dial;
|
||
margin: 0;
|
||
}
|
||
|
||
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-actions] {
|
||
grid-area: actions;
|
||
margin-top: var(--md-sys-measurement-space50);
|
||
}
|
||
}
|
||
|
||
@media (orientation: landscape) and (height <= 364px) {
|
||
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial] {
|
||
--dial: 238px;
|
||
}
|
||
}
|
||
|
||
@media (orientation: landscape) and (height <= 346px) {
|
||
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial] {
|
||
--dial: 200px;
|
||
}
|
||
}
|
||
}
|