herogpui-components 0.10.0

HeroUI-style component library for GPUI
Documentation
//! CloseButton geometry and press against pinned HeroUI v3.2.4
//! `packages/styles/components/close-button.css`.
//!
//! The stylesheet's interactive contract is:
//! - `.close-button--default:active, &[data-pressed="true"]` is
//!   `transform: scale(0.93)` about `origin-center`, not an opacity dim.
//! - `.close-button svg` is `size-4 shrink-0 self-center -mx-0.5 my-0.5`.
//! - hover is `bg-default-hover` only; focus-visible is `status-focused`;
//!   disabled is `status-disabled` (dim, no pointer, no press).
//!
//! gpui has no div-level transform, so the press is the same centred inset
//! `anim::pressed` uses: the 24px box shrinks to `24 * 0.93` and the leftover
//! margin keeps neighbours from reflowing. The shrink ramps over the
//! stylesheet's `transform 250ms var(--ease-out-quart)`; reduced motion keeps
//! the endpoint geometry but arrives on the first frame. Pointer / keyboard
//! activation and render-prop state stay in `buttons.rs` and
//! `value_props.rs`.

mod harness;

use gpui::{
    point, prelude::*, Bounds, ElementId, Modifiers, MouseButton, Pixels, TestAppContext,
    VisualTestContext,
};
use harness::{events, open_host, press};
use herogpui_components::CloseButton;

/// Pinned `.close-button--default:active { transform: scale(0.93) }`.
const PRESS_SCALE: f32 = 0.93;
/// `.close-button` is `h-6 w-6`.
const BOX: f32 = 24.;
/// `.close-button svg` is `size-4`.
const ICON: f32 = 16.;
/// Tailwind `0.5` at a 16px root: `-mx-0.5` / `my-0.5`.
const SVG_MARGIN: f32 = 2.;

fn flush_frame(cx: &mut VisualTestContext) {
    cx.update(|window, _| window.refresh());
}

/// How many animation-frame callbacks are pending: one per live animation.
/// The press ramp registers one while it runs and stops when it settles, the
/// way `checkbox_motion.rs` observes motion.
fn pending_frames(cx: &mut VisualTestContext) -> usize {
    cx.update(|window, cx| window.simulate_next_frame(cx))
}

/// Drains frames until every in-flight animation has settled, failing if one
/// never does (a re-arming animation would keep registering callbacks).
fn settle(cx: &mut VisualTestContext) {
    for _ in 0..100 {
        if pending_frames(cx) == 0 {
            return;
        }
        std::thread::sleep(std::time::Duration::from_millis(10));
    }
    panic!("close button animations never settled");
}

fn probe(name: String) -> &'static str {
    Box::leak(name.into_boxed_str())
}

fn root_probe(id: &'static str) -> &'static str {
    probe(format!("{:?}", ElementId::from(id)))
}

fn icon_probe(id: &'static str) -> &'static str {
    probe(format!("{:?}-icon", ElementId::from(id)))
}

fn near(a: impl Into<Pixels>, b: f32) -> bool {
    (f32::from(a.into()) - b).abs() < 0.5
}

fn bounds(cx: &mut VisualTestContext, name: &'static str) -> Bounds<Pixels> {
    cx.debug_bounds(name)
        .unwrap_or_else(|| panic!("{name} must paint"))
}

fn centre(b: Bounds<Pixels>) -> gpui::Point<Pixels> {
    point(
        b.origin.x + b.size.width / 2.,
        b.origin.y + b.size.height / 2.,
    )
}

// ---------------------------------------------------------------------------
// Pinned source
// ---------------------------------------------------------------------------

#[cfg(test)]
mod pinned_source {
    fn source() -> &'static str {
        include_str!("../src/close_button.rs")
            .split("#[cfg(test)]")
            .next()
            .expect("the implementation section is always present")
    }

    fn code() -> String {
        source()
            .lines()
            .filter(|line| !line.trim_start().starts_with("//"))
            .collect::<Vec<_>>()
            .join("\n")
    }

    /// `.close-button--default:active, &[data-pressed="true"]` is
    /// `transform: scale(0.93)`. The previous port dimmed to 70% opacity.
    #[test]
    fn press_uses_the_pinned_0_93_scale_not_opacity() {
        let code = code();
        assert!(
            code.contains("0.93"),
            "CloseButton must apply the pinned press scale of 0.93"
        );
        assert!(
            !code.contains("opacity(0.7)"),
            "CloseButton must not substitute a dim for the pinned press scale"
        );
    }

    /// `.close-button svg` is `-mx-0.5 my-0.5` (2px at the 16px root).
    #[test]
    fn default_svg_uses_the_pinned_margins() {
        let code = code();
        assert!(
            code.contains("mx(px(-2.))") && code.contains("my(px(2.))"),
            "the default CloseButton glyph must take the pinned -mx-0.5 my-0.5 \
             margins, representable as px(-2)/px(2)"
        );
    }

    /// Reduced motion removes transition timing, not the pinned press
    /// transform (`motion-reduce:transition-none` keeps the property values).
    /// The press rides the shared 250ms `--ease-out-quart` ramp, and disabled
    /// stays off the press path entirely.
    #[test]
    fn press_scale_is_instant_and_enabled_only() {
        let src = source();
        assert!(
            src.contains("const PRESS_SCALE: f32 = 0.93;"),
            "CloseButton must keep the pinned 0.93 press scale at the call site"
        );
        assert!(
            src.contains("crate::anim::pressed_with_background_ramp(")
                && src.contains("crate::anim::CLOSE_BUTTON_PRESS"),
            "the press must ride the shared ramp with the pinned close-button \
             timing, not an instant style swap"
        );
        assert!(
            !src.contains(".active("),
            "the press is keyed state and a timeline now, not GPUI's active style"
        );
        assert!(
            !src.contains("opacity(0.7)"),
            "CloseButton must not substitute a dim for the pinned press scale"
        );
    }
}

// ---------------------------------------------------------------------------
// Press geometry
// ---------------------------------------------------------------------------

/// A pointer press must ramp the 24px box down to `24 * 0.93` about its
/// centre — starting from the painted frame, animating over the pinned 250ms,
/// settling at the pinned endpoint — and spring back the same way, while the
/// completed click still reports `on_press`.
#[gpui::test]
fn press_scales_the_box_to_0_93(cx: &mut TestAppContext) {
    let presses = events();
    let recorded = presses.clone();
    let cx = open_host(cx, move || {
        let recorded = recorded.clone();
        CloseButton::new("cb")
            .on_press(move |_, _, _| recorded.borrow_mut().push("press".into()))
            .into_any_element()
    });

    let root = root_probe("cb");
    flush_frame(cx);
    let at_rest = bounds(cx, root);
    assert!(
        near(at_rest.size.width, BOX) && near(at_rest.size.height, BOX),
        "a resting CloseButton is a 24px square, got {at_rest:?}"
    );

    let at = centre(at_rest);
    cx.simulate_mouse_move(at, None, Modifiers::none());
    flush_frame(cx);
    // The hover fade eases its own fill here, so pending frames are expected;
    // what hover must never do is move the box.
    let hovered = bounds(cx, root);
    assert!(
        near(hovered.size.width, BOX) && near(hovered.size.height, BOX),
        "hover must not change the box, got {hovered:?}"
    );

    cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
    flush_frame(cx);
    let starting = bounds(cx, root);
    assert!(
        near(starting.size.width, BOX),
        "the ramp starts from the painted frame, not the endpoint, got {starting:?}"
    );
    assert!(
        pending_frames(cx) > 0,
        "a press must start the transform ramp, not snap"
    );

    settle(cx);
    let pressed = bounds(cx, root);
    let scaled = BOX * PRESS_SCALE;
    assert!(
        near(pressed.size.width, scaled) && near(pressed.size.height, scaled),
        "a pressed CloseButton must scale to 0.93 ({scaled}px), got {pressed:?}"
    );
    let inset = (BOX - scaled) / 2.;
    assert!(
        near(pressed.origin.x, inset) && near(pressed.origin.y, inset),
        "the scale is about the centre, so the leftover is margin, got {pressed:?}"
    );

    cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
    flush_frame(cx);
    assert!(
        pending_frames(cx) > 0,
        "the release must ramp back rather than snap"
    );
    settle(cx);
    let released = bounds(cx, root);
    assert!(
        near(released.size.width, BOX) && near(released.size.height, BOX),
        "the box must spring back after the release, got {released:?}"
    );
    assert_eq!(
        presses.borrow().as_slice(),
        ["press"],
        "the completed click must still report on_press"
    );
}

/// `motion-reduce:transition-none` still applies the CSS transform, so the
/// pressed box must be at its final geometry on the first reduced-motion frame.
/// Activation must survive that instant state change.
#[gpui::test]
fn reduced_motion_keeps_the_press_scale_instantly(cx: &mut TestAppContext) {
    harness::still();
    let presses = events();
    let recorded = presses.clone();
    let cx = open_host(cx, move || {
        let recorded = recorded.clone();
        CloseButton::new("cb-still")
            .on_press(move |_, _, _| recorded.borrow_mut().push("press".into()))
            .into_any_element()
    });

    let root = root_probe("cb-still");
    flush_frame(cx);
    let at = centre(bounds(cx, root));
    cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
    flush_frame(cx);
    let pressed = bounds(cx, root);
    let scaled = BOX * PRESS_SCALE;
    assert!(
        near(pressed.size.width, scaled) && near(pressed.size.height, scaled),
        "reduced motion must apply the press scale immediately ({scaled}px), got {pressed:?}"
    );
    let inset = (BOX - scaled) / 2.;
    assert!(
        near(pressed.origin.x, inset) && near(pressed.origin.y, inset),
        "reduced-motion press must stay centered, got {pressed:?}"
    );
    cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
    flush_frame(cx);
    assert_eq!(
        presses.borrow().as_slice(),
        ["press"],
        "reduced motion must not swallow the press"
    );
}

/// A disabled CloseButton is `status-disabled`: no hover fill, no press
/// scale, no activation.
#[gpui::test]
fn disabled_press_does_not_scale_or_fire(cx: &mut TestAppContext) {
    let presses = events();
    let recorded = presses.clone();
    let cx = open_host(cx, move || {
        let recorded = recorded.clone();
        CloseButton::new("cb-off")
            .is_disabled(true)
            .on_press(move |_, _, _| recorded.borrow_mut().push("press".into()))
            .into_any_element()
    });

    let root = root_probe("cb-off");
    flush_frame(cx);
    let at = centre(bounds(cx, root));
    cx.simulate_mouse_move(at, None, Modifiers::none());
    cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none());
    flush_frame(cx);
    let pressed = bounds(cx, root);
    assert!(
        near(pressed.size.width, BOX) && near(pressed.size.height, BOX),
        "a disabled CloseButton must not scale, got {pressed:?}"
    );
    cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none());
    flush_frame(cx);
    assert!(
        presses.borrow().is_empty(),
        "a disabled CloseButton must not report on_press"
    );
}

/// Keyboard focus draws `status-focused` as a ring; it must not change the
/// 24px box. Tab still lands on the enabled button.
#[gpui::test]
fn focus_visible_keeps_the_24px_box(cx: &mut TestAppContext) {
    let cx = open_host(cx, || CloseButton::new("cb-focus").into_any_element());
    let root = root_probe("cb-focus");
    flush_frame(cx);
    press(cx, "tab");
    flush_frame(cx);
    let focused = bounds(cx, root);
    assert!(
        near(focused.size.width, BOX) && near(focused.size.height, BOX),
        "focus-visible must not change the box, got {focused:?}"
    );
}

// ---------------------------------------------------------------------------
// Default glyph placement
// ---------------------------------------------------------------------------

/// `.close-button` is `h-6 w-6 p-1` around a `size-4` glyph. Symmetric
/// `-mx-0.5 my-0.5` on a centred 16px child in a 16px content box cancel,
/// so the glyph still fills the padding box at (4, 4). The margins are
/// still applied (source scan above); this pins the observable placement.
#[gpui::test]
fn default_glyph_fills_the_padded_content_box(cx: &mut TestAppContext) {
    let cx = open_host(cx, || CloseButton::new("cb-icon").into_any_element());
    let icon = icon_probe("cb-icon");
    flush_frame(cx);
    let glyph = bounds(cx, icon);
    assert!(
        near(glyph.size.width, ICON) && near(glyph.size.height, ICON),
        "the default glyph is size-4 (16px), got {glyph:?}"
    );
    assert!(
        near(glyph.origin.x, 4.) && near(glyph.origin.y, 4.),
        "items-center + justify-center keep a 16px glyph at the p-1 inset, \
         got {glyph:?}"
    );
    let _ = SVG_MARGIN;
}

/// `hover_bg` and the `sx` background feed the shared endpoint resolver: the
/// fade rests on the sx background (or `--default`) and eases to the named
/// hover colour. Resolver cases live with `util::fade_endpoints`; this pins
/// that the close button reads both inputs.
#[test]
fn close_button_reads_the_hover_override_and_the_sx_background() {
    let source = include_str!("../src/close_button.rs");
    assert!(
        source.contains("crate::util::sx_background(&self.sx)"),
        "the resting endpoint must come from the sx background"
    );
    assert!(
        source.contains("crate::util::fade_endpoints("),
        "the close button must resolve its fade through the shared resolver"
    );
    assert!(
        source.contains("self.hover_bg = Some(color.into());"),
        "the hover_bg builder must store the override"
    );
}