| 72 |
72 |
|
//! decides which wins. That is the one real risk in adopting this, and it is
|
| 73 |
73 |
|
//! why the migration lands per component rather than in one commit.
|
| 74 |
74 |
|
//!
|
|
75 |
+ |
//! # 0.10.0: the states this crate used to leave to its consumers
|
|
76 |
+ |
//!
|
|
77 |
+ |
//! [`interactive_rules`] emitted hover and pressed and stopped, because
|
|
78 |
+ |
//! `makeover-layout` modelled no interaction state. Focus and disabled were
|
|
79 |
+ |
//! therefore unsayable, and every app completed the primitive from outside the
|
|
80 |
+ |
//! only way that works: by out-specifying a rule it does not own. goingson
|
|
81 |
+ |
//! carries 19 such rules and the MNW server 21, and the three focus rings do
|
|
82 |
+ |
//! not match each other.
|
|
83 |
+ |
//!
|
|
84 |
+ |
//! That also blocked the cascade-layer work outright. An app that declares
|
|
85 |
+ |
//! `@layer` puts its own rules in a named layer, and unlayered declarations
|
|
86 |
+ |
//! outrank every named layer regardless of specificity, so all of those
|
|
87 |
+ |
//! overrides lose in the commit that adopts layers. They cannot simply be
|
|
88 |
+ |
//! deleted, because they are the only thing supplying the missing states.
|
|
89 |
+ |
//! Emitting the states here is what turns that adoption into a deletion.
|
|
90 |
+ |
//!
|
|
91 |
+ |
//! Four states now, in emission order, and the order is load-bearing: they are
|
|
92 |
+ |
//! all specificity (0,2,0), so disabled beats hover by coming last and by
|
|
93 |
+ |
//! nothing else. Nothing here reaches for `:not(:disabled)`, which would raise
|
|
94 |
+ |
//! a selector this crate will shortly be wrapping in its own layer.
|
|
95 |
+ |
//!
|
|
96 |
+ |
//! Hover additionally sits inside a capability query now. `makeover-touch`
|
|
97 |
+ |
//! answers whether a fingertip has hover and `makeover-geometry` spells the
|
|
98 |
+ |
//! condition; this crate asks and does not decide. goingson's section 60 exists
|
|
99 |
+ |
//! solely to take the hover state back on touch, which is a fight it should
|
|
100 |
+ |
//! never have been handed.
|
|
101 |
+ |
//!
|
| 75 |
102 |
|
//! # Substitution, three ways
|
| 76 |
103 |
|
//!
|
| 77 |
104 |
|
//! `Fill::Well` has no colour on makeover before 2.3.0, and each renderer
|
| 90 |
117 |
|
pub mod form;
|
| 91 |
118 |
|
pub mod list;
|
| 92 |
119 |
|
|
| 93 |
|
- |
use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, Token, Tone};
|
|
120 |
+ |
use makeover_geometry::{Density, SizeClass};
|
|
121 |
+ |
use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, State, Token, Tone};
|
|
122 |
+ |
use makeover_touch::Affordance;
|
| 94 |
123 |
|
use std::fmt::Write as _;
|
| 95 |
124 |
|
|
| 96 |
125 |
|
/// How the emitted CSS is shaped.
|
| 229 |
258 |
|
format!(".{selector} {{\n{body}}}\n")
|
| 230 |
259 |
|
}
|
| 231 |
260 |
|
|
| 232 |
|
- |
/// Hover and pressed, for a selector that answers a click.
|
|
261 |
+ |
/// The media condition a hover rule has to sit inside, or `None` if hover is
|
|
262 |
+ |
/// unconditional.
|
|
263 |
+ |
///
|
|
264 |
+ |
/// Two crates answer this and neither answer is made here. `makeover-touch`
|
|
265 |
+ |
/// owns *whether* hover exists at a density, and `makeover-geometry` owns how
|
|
266 |
+ |
/// that capability is spelled as a media condition. Asking both is what stops
|
|
267 |
+ |
/// this renderer minting a third opinion, which is what all three apps did:
|
|
268 |
+ |
/// goingson sniffed the user agent, Balanced Breakfast used `(hover: none)`
|
|
269 |
+ |
/// alone, and the MNW server had no gate at all.
|
|
270 |
+ |
///
|
|
271 |
+ |
/// [`SizeClass`] is required by [`Affordance::available`] and ignored by this
|
|
272 |
+ |
/// member, which reports as much through `reads_size`. Passing Compact is not
|
|
273 |
+ |
/// a claim about width; the test below pins that every class agrees.
|
|
274 |
+ |
fn hover_condition() -> Option<&'static str> {
|
|
275 |
+ |
if Affordance::Hover.available(Density::Touch, SizeClass::Compact) {
|
|
276 |
+ |
// A fingertip grew a hover state. Nothing to gate, and this renderer
|
|
277 |
+ |
// should not invent a reason to gate anyway.
|
|
278 |
+ |
None
|
|
279 |
+ |
} else {
|
|
280 |
+ |
Some(Density::Pointer.media_condition())
|
|
281 |
+ |
}
|
|
282 |
+ |
}
|
|
283 |
+ |
|
|
284 |
+ |
/// Put a rule inside a media query, or leave it alone.
|
|
285 |
+ |
fn gated(condition: Option<&str>, rule: &str) -> String {
|
|
286 |
+ |
let Some(condition) = condition else {
|
|
287 |
+ |
return rule.to_string();
|
|
288 |
+ |
};
|
|
289 |
+ |
let mut css = format!("@media {condition} {{\n");
|
|
290 |
+ |
for line in rule.lines() {
|
|
291 |
+ |
// Blank lines stay blank. Indenting one leaves trailing whitespace,
|
|
292 |
+ |
// which is the sort of thing a formatter later reverts and calls a diff.
|
|
293 |
+ |
if line.is_empty() {
|
|
294 |
+ |
css.push('\n');
|
|
295 |
+ |
} else {
|
|
296 |
+ |
let _ = writeln!(css, " {line}");
|
|
297 |
+ |
}
|
|
298 |
+ |
}
|
|
299 |
+ |
css.push_str("}\n");
|
|
300 |
+ |
css
|
|
301 |
+ |
}
|
|
302 |
+ |
|
|
303 |
+ |
/// The keyboard focus ring, placed by the depth it lands on.
|
|
304 |
+ |
///
|
|
305 |
+ |
/// One ring for the whole system, because a focus ring's job is to be
|
|
306 |
+ |
/// recognised and three apps having three of them is the failure. What varies
|
|
307 |
+ |
/// is where it sits, and that comes off [`Depth`] rather than off a per-
|
|
308 |
+ |
/// component choice: a well takes the ring inside its own edge, and anything
|
|
309 |
+ |
/// standing proud of the page takes it outside.
|
|
310 |
+ |
///
|
|
311 |
+ |
/// `outline` rather than the composed `box-shadow` the invalid-field ring at
|
|
312 |
+ |
/// [`field_rules`] uses, and deliberately the one place the two rings are built
|
|
313 |
+ |
/// differently. A `box-shadow` ring has to restate the bevel beside it, because
|
|
314 |
+ |
/// `box-shadow` is not additive and a lone ring silently drops the well out
|
|
315 |
+ |
/// from under the element. That restatement is a second copy of the depth,
|
|
316 |
+ |
/// living in a different function from the first, and it is exactly the
|
|
317 |
+ |
/// duplication `Depth` exists to prevent. `outline` occupies its own property,
|
|
318 |
+ |
/// so the bevel survives untouched and there is nothing to keep in agreement.
|
|
319 |
+ |
/// They render the same: both are a flush ring one border-width wide.
|
|
320 |
+ |
#[must_use]
|
|
321 |
+ |
pub fn focus_rule(selector: &str, depth: Depth, opts: &Emit) -> String {
|
|
322 |
+ |
let w = opts.border_width;
|
|
323 |
+ |
let offset = match depth.bevel() {
|
|
324 |
+ |
// Inside the well, clear of the inset edge rather than painted over
|
|
325 |
+ |
// it. Two widths in: one to cross the edge, one to stand off it.
|
|
326 |
+ |
Some(Bevel::Inset) => format!("calc(-2 * {w})"),
|
|
327 |
+ |
// Raised, or no edge at all. Outside, standing off by its own width.
|
|
328 |
+ |
_ => w.to_string(),
|
|
329 |
+ |
};
|
|
330 |
+ |
format!(
|
|
331 |
+ |
".{selector}:focus-visible {{\n outline: {w} solid var(--{});\n outline-offset: {offset};\n}}\n",
|
|
332 |
+ |
State::Focus.token()
|
|
333 |
+ |
)
|
|
334 |
+ |
}
|
|
335 |
+ |
|
|
336 |
+ |
/// Present, visible, and not answering.
|
|
337 |
+ |
///
|
|
338 |
+ |
/// Matches the ARIA attribute as well as the pseudo-class, because `:disabled`
|
|
339 |
+ |
/// only matches form elements and half the things this crate emits are not
|
|
340 |
+ |
/// one: a `div` carrying `.chip` or `.tab` can never be `:disabled`. Keying on
|
|
341 |
+ |
/// the accessible state is the pattern [`field_rules`] already establishes for
|
|
342 |
+ |
/// `aria-invalid`, on the reasoning that one fact read by both the styling and
|
|
343 |
+ |
/// the accessibility tree cannot drift from itself.
|
|
344 |
+ |
///
|
|
345 |
+ |
/// The rest depth is re-asserted rather than assumed, because this rule has to
|
|
346 |
+ |
/// beat the hover and pressed rules above it. It does that on source order at
|
|
347 |
+ |
/// equal specificity, not by out-specifying them: every rule this function's
|
|
348 |
+ |
/// caller emits is (0,2,0), and adding a `:not(:disabled)` anywhere would raise
|
|
349 |
+ |
/// one of them and have to be unpicked when this output moves inside its own
|
|
350 |
+ |
/// cascade layer.
|
|
351 |
+ |
#[must_use]
|
|
352 |
+ |
pub fn disabled_rule(selector: &str, depth: Depth) -> String {
|
|
353 |
+ |
format!(
|
|
354 |
+ |
".{selector}:disabled,\n.{selector}[aria-disabled=\"true\"] {{\n{} color: var(--{});\n cursor: not-allowed;\n}}\n",
|
|
355 |
+ |
depth_declarations(depth),
|
|
356 |
+ |
State::Disabled.token()
|
|
357 |
+ |
)
|
|
358 |
+ |
}
|
|
359 |
+ |
|
|
360 |
+ |
/// Every state a selector that answers a click implies: hover, pressed, focus
|
|
361 |
+ |
/// and disabled, in that order.
|
|
362 |
+ |
///
|
|
363 |
+ |
/// Order is the whole cascade mechanism here. All four selectors are
|
|
364 |
+ |
/// specificity (0,2,0), so disabled wins over hover and pressed by coming last
|
|
365 |
+ |
/// and by nothing else.
|
| 233 |
366 |
|
///
|
| 234 |
367 |
|
/// Pressed emits [`Depth::pressed`] in full, fill and edge together. Emitting
|
| 235 |
368 |
|
/// only the edge is what left goingson hand-writing `background:
|
| 241 |
374 |
|
/// Hover has no member in the description and is renderer policy: a terminal
|
| 242 |
375 |
|
/// and an immediate-mode painter have no hover to express. It resolves against
|
| 243 |
376 |
|
/// `--hover-surface`, which `makeover` already derives and which nothing
|
| 244 |
|
- |
/// consumed until now.
|
|
377 |
+ |
/// consumed until now. What it *is* gated on is capability, via
|
|
378 |
+ |
/// [`hover_condition`]. Before that gate existed the apps each wrote their own:
|
|
379 |
+ |
/// goingson's section 60 exists solely to take back the hover state this
|
|
380 |
+ |
/// function had just handed it, by out-specifying a rule it does not own.
|
|
381 |
+ |
///
|
|
382 |
+ |
/// `depth` is the selector's **rest** depth, used to place the focus ring and
|
|
383 |
+ |
/// to restore the surface under a disabled control. The pressed rule keeps
|
|
384 |
+ |
/// inverting from [`Depth::Raised`] regardless, which is what every caller got
|
|
385 |
+ |
/// before this parameter existed: a tab's unchosen depth is
|
|
386 |
+ |
/// [`Depth::Sunken`], and `Sunken.pressed()` is `Sunken`, so deriving the press
|
|
387 |
+ |
/// from the rest depth would leave a tab with no press at all.
|
| 245 |
388 |
|
#[must_use]
|
| 246 |
|
- |
pub fn interactive_rules(selector: &str) -> String {
|
| 247 |
|
- |
format!(
|
| 248 |
|
- |
".{selector}:hover {{\n background: var(--hover-surface);\n}}\n{}",
|
| 249 |
|
- |
depth_rule(&format!("{selector}:active"), Depth::Raised.pressed())
|
| 250 |
|
- |
)
|
|
389 |
+ |
pub fn interactive_rules(selector: &str, depth: Depth, opts: &Emit) -> String {
|
|
390 |
+ |
let mut css = gated(
|
|
391 |
+ |
hover_condition(),
|
|
392 |
+ |
&format!(".{selector}:hover {{\n background: var(--hover-surface);\n}}\n"),
|
|
393 |
+ |
);
|
|
394 |
+ |
css.push_str(&depth_rule(
|
|
395 |
+ |
&format!("{selector}:active"),
|
|
396 |
+ |
Depth::Raised.pressed(),
|
|
397 |
+ |
));
|
|
398 |
+ |
css.push_str(&focus_rule(selector, depth, opts));
|
|
399 |
+ |
css.push_str(&disabled_rule(selector, depth));
|
|
400 |
+ |
css
|
| 251 |
401 |
|
}
|
| 252 |
402 |
|
|
| 253 |
403 |
|
/// One rule per depth: its fill and its edge, together.
|
| 265 |
415 |
|
css.push_str(&depth_rule(&class, depth));
|
| 266 |
416 |
|
}
|
| 267 |
417 |
|
if let Some(raised) = depth_class(Depth::Raised, opts) {
|
| 268 |
|
- |
css.push_str(&interactive_rules(&raised));
|
|
418 |
+ |
css.push_str(&interactive_rules(&raised, Depth::Raised, opts));
|
| 269 |
419 |
|
}
|
| 270 |
420 |
|
css
|
| 271 |
421 |
|
}
|
| 283 |
433 |
|
for name in ["button", "card"] {
|
| 284 |
434 |
|
let c = class(name, opts);
|
| 285 |
435 |
|
css.push_str(&depth_rule(&c, Depth::Raised));
|
| 286 |
|
- |
css.push_str(&interactive_rules(&c));
|
|
436 |
+ |
css.push_str(&interactive_rules(&c, Depth::Raised, opts));
|
| 287 |
437 |
|
}
|
| 288 |
438 |
|
|
| 289 |
439 |
|
let field = class("field", opts);
|
| 290 |
440 |
|
css.push_str(&depth_rule(&field, Depth::Well));
|
| 291 |
441 |
|
|
|
442 |
+ |
// A field takes focus and refuses input like everything else here, and got
|
|
443 |
+ |
// neither until now, which is why all three apps hand-write a focus ring
|
|
444 |
+ |
// for it and no two of them match. No hover or pressed: a text field does
|
|
445 |
+ |
// not light up under the pointer and does not invert when clicked, so the
|
|
446 |
+ |
// two states `interactive_rules` would add are the two it does not have.
|
|
447 |
+ |
css.push_str(&focus_rule(&field, Depth::Well, opts));
|
|
448 |
+ |
css.push_str(&disabled_rule(&field, Depth::Well));
|
|
449 |
+ |
|
| 292 |
450 |
|
// Keyed on the ARIA attribute rather than on a class, so the visual state
|
| 293 |
451 |
|
// and the accessible state cannot drift apart: there is one fact and both
|
| 294 |
452 |
|
// read it. goingson already drove its invalid styling this way and was
|
| 344 |
502 |
|
let chip = class("chip", opts);
|
| 345 |
503 |
|
let unlatched = Token::Chip { removable: false };
|
| 346 |
504 |
|
css.push_str(&depth_rule(&chip, unlatched.depth(false)));
|
| 347 |
|
- |
css.push_str(&interactive_rules(&chip));
|
|
505 |
+ |
css.push_str(&interactive_rules(&chip, unlatched.depth(false), opts));
|
| 348 |
506 |
|
css.push_str(&depth_rule(
|
| 349 |
507 |
|
&format!("{chip}.latched"),
|
| 350 |
508 |
|
unlatched.depth(true),
|
| 375 |
533 |
|
] {
|
| 376 |
534 |
|
let c = class(name, opts);
|
| 377 |
535 |
|
css.push_str(&depth_rule(&c, selector.unchosen()));
|
| 378 |
|
- |
css.push_str(&interactive_rules(&c));
|
|
536 |
+ |
css.push_str(&interactive_rules(&c, selector.unchosen(), opts));
|
| 379 |
537 |
|
css.push_str(&depth_rule(&format!("{c}.chosen"), selector.chosen()));
|
| 380 |
538 |
|
}
|
| 381 |
539 |
|
css
|
| 426 |
584 |
|
css,
|
| 427 |
585 |
|
".{c} {{\n opacity: 0;\n pointer-events: none;\n}}"
|
| 428 |
586 |
|
);
|
| 429 |
|
- |
let _ = writeln!(
|
| 430 |
|
- |
css,
|
| 431 |
|
- |
".{row}:hover .{c},\n.{row}:focus-within .{c} {{\n opacity: 1;\n pointer-events: auto;\n}}"
|
| 432 |
|
- |
);
|
|
587 |
+ |
|
|
588 |
+ |
// The two halves split here, where they used to be one selector
|
|
589 |
+ |
// list. Hover-to-reveal is the literal case `Affordance::Hover`
|
|
590 |
+ |
// was written from, and on a touchscreen it does not fail
|
|
591 |
+ |
// gracefully: the actions are simply unreachable, because there
|
|
592 |
+ |
// is no pointer to bring them back. So the hover half is gated
|
|
593 |
+ |
// and the app owes those rows another way in.
|
|
594 |
+ |
//
|
|
595 |
+ |
// `focus-within` stays outside the query. A touchscreen device
|
|
596 |
+ |
// with a keyboard attached is a real thing, and it is the one
|
|
597 |
+ |
// path to these actions that survives the gate.
|
|
598 |
+ |
let revealed = " opacity: 1;\n pointer-events: auto;\n";
|
|
599 |
+ |
css.push_str(&gated(
|
|
600 |
+ |
hover_condition(),
|
|
601 |
+ |
&format!(".{row}:hover .{c} {{\n{revealed}}}\n"),
|
|
602 |
+ |
));
|
|
603 |
+ |
let _ = write!(css, ".{row}:focus-within .{c} {{\n{revealed}}}\n");
|
| 433 |
604 |
|
}
|
| 434 |
605 |
|
}
|
| 435 |
606 |
|
css
|
| 575 |
746 |
|
// bevel flip alone is what left goingson hand-writing `background:
|
| 576 |
747 |
|
// var(--surface-sunken)` on .btn, .card and .tag/.badge alike, so none
|
| 577 |
748 |
|
// of the three could be deleted.
|
| 578 |
|
- |
let pressed = interactive_rules("button");
|
|
749 |
+ |
let pressed = interactive_rules("button", Depth::Raised, &Emit::default());
|
| 579 |
750 |
|
assert!(pressed.contains(".button:active {"));
|
| 580 |
751 |
|
assert!(
|
| 581 |
752 |
|
pressed.contains("background: var(--surface-well, var(--surface-page))"),
|
| 615 |
786 |
|
);
|
| 616 |
787 |
|
}
|
| 617 |
788 |
|
|
|
789 |
+ |
#[test]
|
|
790 |
+ |
fn a_primitive_owns_every_state_it_implies() {
|
|
791 |
+ |
// The whole point of 0.10.0. Anything emitting a hover rule owes the
|
|
792 |
+ |
// other three, or the consuming app supplies them by out-specifying a
|
|
793 |
+ |
// rule it does not own: 19 such rules in goingson, 21 in the MNW
|
|
794 |
+ |
// server, and three focus rings that do not match.
|
|
795 |
+ |
let css = stylesheet(&Emit::default());
|
|
796 |
+ |
for selector in ["button", "card", "chip", "tab", "segment", "toggle"] {
|
|
797 |
+ |
assert!(css.contains(&format!(".{selector}:hover {{")), "{selector}");
|
|
798 |
+ |
assert!(css.contains(&format!(".{selector}:active {{")), "{selector}");
|
|
799 |
+ |
assert!(
|
|
800 |
+ |
css.contains(&format!(".{selector}:focus-visible {{")),
|
|
801 |
+ |
"{selector} has no focus ring"
|
|
802 |
+ |
);
|
|
803 |
+ |
assert!(
|
|
804 |
+ |
css.contains(&format!(".{selector}:disabled,")),
|
|
805 |
+ |
"{selector} has no disabled state"
|
|
806 |
+ |
);
|
|
807 |
+ |
}
|
|
808 |
+ |
}
|
|
809 |
+ |
|
|
810 |
+ |
#[test]
|
|
811 |
+ |
fn a_field_takes_focus_and_refuses_input_without_taking_a_hover() {
|
|
812 |
+ |
// A text field does not light up under the pointer, so it gets the two
|
|
813 |
+ |
// states it has and not the two it does not.
|
|
814 |
+ |
let css = stylesheet(&Emit::default());
|
|
815 |
+ |
assert!(css.contains(".field:focus-visible {"));
|
|
816 |
+ |
assert!(css.contains(".field:disabled,"));
|
|
817 |
+ |
assert!(!css.contains(".field:hover {"));
|
|
818 |
+ |
assert!(!css.contains(".field:active {"));
|
|
819 |
+ |
}
|
|
820 |
+ |
|
|
821 |
+ |
#[test]
|
|
822 |
+ |
fn disabled_is_emitted_after_hover_so_source_order_settles_it() {
|
|
823 |
+ |
// Every one of these selectors is specificity (0,2,0), so nothing but
|
|
824 |
+ |
// order decides which wins. A disabled button taking the hover fill is
|
|
825 |
+ |
// the exact bug goingson's `.button:disabled:hover` was written to fix,
|
|
826 |
+ |
// and the reason it had to reach (0,3,0) to do it.
|
|
827 |
+ |
let css = interactive_rules("button", Depth::Raised, &Emit::default());
|
|
828 |
+ |
let hover = css.find(":hover").expect("hover");
|
|
829 |
+ |
let active = css.find(":active").expect("active");
|
|
830 |
+ |
let focus = css.find(":focus-visible").expect("focus");
|
|
831 |
+ |
let disabled = css.find(":disabled").expect("disabled");
|
|
832 |
+ |
assert!(hover < active && active < focus && focus < disabled);
|
|
833 |
+ |
|
|
834 |
+ |
// And it restores the surface, or the hover fill survives underneath.
|
|
835 |
+ |
let tail = &css[disabled..];
|
|
836 |
+ |
assert!(tail.contains("background: var(--surface-raised)"));
|
|
837 |
+ |
}
|
|
838 |
+ |
|
|
839 |
+ |
#[test]
|
|
840 |
+ |
fn a_disabled_state_reaches_things_that_cannot_be_disabled() {
|
|
841 |
+ |
// `:disabled` matches form elements only, and a chip is a div. Keying
|
|
842 |
+ |
// on the ARIA attribute too is the pattern the invalid field already
|
|
843 |
+ |
// set: one fact, read by the styling and the accessibility tree alike.
|
|
844 |
+ |
let css = disabled_rule("chip", Depth::Raised);
|
|
845 |
+ |
assert!(css.contains(".chip:disabled,"));
|
|
846 |
+ |
assert!(css.contains(".chip[aria-disabled=\"true\"]"));
|
|
847 |
+ |
assert!(css.contains("cursor: not-allowed"));
|
|
848 |
+ |
}
|
|
849 |
+ |
|
|
850 |
+ |
#[test]
|
|
851 |
+ |
fn the_focus_ring_does_not_disturb_the_bevel_it_lands_on() {
|
|
852 |
+ |
// `outline` has its own property, so unlike the invalid ring there is
|
|
853 |
+ |
// no bevel to restate beside it and nothing to keep in agreement.
|
|
854 |
+ |
let opts = Emit::default();
|
|
855 |
+ |
let css = focus_rule("button", Depth::Raised, &opts);
|
|
856 |
+ |
assert!(css.contains("outline: 1px solid var(--focus-ring)"));
|
|
857 |
+ |
assert!(!css.contains("box-shadow"), "the ring restated the bevel");
|
|
858 |
+ |
}
|
|
859 |
+ |
|
|
860 |
+ |
#[test]
|
|
861 |
+ |
fn a_well_takes_the_ring_inside_and_a_raised_surface_outside() {
|
|
862 |
+ |
// One ring, placed by depth. The offset comes off `Depth::bevel` and
|
|
863 |
+ |
// not off a per-component choice, which is what gave three apps three
|
|
864 |
+ |
// different rings.
|
|
865 |
+ |
let opts = Emit::default();
|
|
866 |
+ |
assert!(focus_rule("field", Depth::Well, &opts).contains("outline-offset: calc(-2 * 1px)"));
|
|
867 |
+ |
assert!(focus_rule("button", Depth::Raised, &opts).contains("outline-offset: 1px"));
|
|
868 |
+ |
// Nothing to sit inside of, so it sits outside.
|
|
869 |
+ |
assert!(focus_rule("badge", Depth::Sunken, &opts).contains("outline-offset: 1px"));
|
|
870 |
+ |
}
|
|
871 |
+ |
|
|
872 |
+ |
#[test]
|
|
873 |
+ |
fn hover_is_gated_on_capability_and_the_keyboard_path_is_not() {
|
|
874 |
+ |
// goingson's section 60 exists only to take back the hover state this
|
|
875 |
+ |
// crate handed it. Gating at the source is what deletes that section
|
|
876 |
+ |
// in all three apps rather than having each fight for it.
|
|
877 |
+ |
let css = stylesheet(&Emit::default());
|
|
878 |
+ |
let condition = format!("@media {}", Density::Pointer.media_condition());
|
|
879 |
+ |
assert!(css.contains(&condition));
|
|
880 |
+ |
|
|
881 |
+ |
// The row reveal splits: hover inside the query, focus-within outside,
|
|
882 |
+ |
// or a touchscreen with a keyboard loses its only way to the actions.
|
|
883 |
+ |
let reveal = css.find(".row:hover .row-actions").expect("hover reveal");
|
|
884 |
+ |
let keyboard = css
|
|
885 |
+ |
.find(".row:focus-within .row-actions")
|
|
886 |
+ |
.expect("keyboard reveal");
|
|
887 |
+ |
let query_end = css[reveal..].find("\n}\n").expect("query closes") + reveal;
|
|
888 |
+ |
assert!(reveal < query_end && query_end < keyboard);
|
|
889 |
+ |
}
|
|
890 |
+ |
|
|
891 |
+ |
#[test]
|
|
892 |
+ |
fn the_capability_answer_is_asked_for_and_not_assumed() {
|
|
893 |
+ |
// Both halves come from the crates that own them. If `makeover-touch`
|
|
894 |
+ |
// ever says a fingertip has hover, this stops gating on its own.
|
|
895 |
+ |
assert!(!Affordance::Hover.available(Density::Touch, SizeClass::Compact));
|
|
896 |
+ |
assert!(Affordance::Hover.available(Density::Pointer, SizeClass::Compact));
|
|
897 |
+ |
assert_eq!(hover_condition(), Some(Density::Pointer.media_condition()));
|
|
898 |
+ |
|
|
899 |
+ |
// And the size class passed to that call is not a claim about width.
|
|
900 |
+ |
assert!(Affordance::Hover.reads_density());
|
|
901 |
+ |
for size in [SizeClass::Compact, SizeClass::Medium, SizeClass::Expanded] {
|
|
902 |
+ |
assert!(!Affordance::Hover.available(Density::Touch, size));
|
|
903 |
+ |
}
|
|
904 |
+ |
}
|
|
905 |
+ |
|
| 618 |
906 |
|
#[test]
|
| 619 |
907 |
|
fn hover_resolves_against_the_token_makeover_already_derives() {
|
| 620 |
|
- |
let css = interactive_rules("card");
|
|
908 |
+ |
let css = interactive_rules("card", Depth::Raised, &Emit::default());
|
| 621 |
909 |
|
assert!(css.contains(".card:hover {"));
|
| 622 |
910 |
|
assert!(css.contains("background: var(--hover-surface)"));
|
| 623 |
911 |
|
// Not the app's choice, which was --surface-overlay.
|
| 832 |
1120 |
|
assert!(css.contains(".button {"));
|
| 833 |
1121 |
|
assert!(css.contains(".card {"));
|
| 834 |
1122 |
|
assert_eq!(
|
| 835 |
|
- |
interactive_rules("button").replace("button", "card"),
|
| 836 |
|
- |
interactive_rules("card")
|
|
1123 |
+ |
interactive_rules("button", Depth::Raised, &Emit::default()).replace("button", "card"),
|
|
1124 |
+ |
interactive_rules("card", Depth::Raised, &Emit::default())
|
| 837 |
1125 |
|
);
|
| 838 |
1126 |
|
}
|
| 839 |
1127 |
|
|
| 883 |
1171 |
|
}
|
| 884 |
1172 |
|
// Everything left has to be a keyword, a number or a
|
| 885 |
1173 |
|
// caller-supplied length, never a colour.
|
|
1174 |
+ |
//
|
|
1175 |
+ |
// The length arm is what the comment above always claimed and the
|
|
1176 |
+ |
// list never covered: `border_width` arrives from `Emit` and lands
|
|
1177 |
+ |
// bare in the focus ring's offset, where the bevel had only ever
|
|
1178 |
+ |
// used it inside an `inset` shadow.
|
|
1179 |
+ |
let width = Emit::default().border_width;
|
| 886 |
1180 |
|
assert!(
|
| 887 |
1181 |
|
value.contains("inset")
|
| 888 |
|
- |
|| matches!(value.trim_end_matches(';'), "0" | "1" | "none" | "auto"),
|
|
1182 |
+ |
|| value.contains(width)
|
|
1183 |
+ |
|| matches!(
|
|
1184 |
+ |
value.trim_end_matches(';'),
|
|
1185 |
+ |
"0" | "1" | "none" | "auto" | "not-allowed"
|
|
1186 |
+ |
),
|
| 889 |
1187 |
|
"unrecognised literal value: {line}"
|
| 890 |
1188 |
|
);
|
| 891 |
1189 |
|
}
|