Skip to main content

herogpui_components/
link.rs

1//! Link — port of `@heroui/link` (v3).
2//!
3//! Mirrors the React API: `href`, `isDisabled`, `autoFocus`, `onPress`, and
4//! the documented `render` function. Links draw with the `--link` token
5//! (which defaults to `--foreground`) — v3 removed the `color` prop — and
6//! `link.css` keeps the text colour fixed across every state: hover and press
7//! change only the underline decoration (`decoration-muted/50`, then
8//! `decoration-muted`), never the text. `href` opens through the OS handler
9//! (`App::open_url`), so v3's anchor-only `target` / `rel` / `download` have
10//! no meaning here and are not offered.
11
12use gpui::{
13    div, prelude::*, px, App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, Pixels,
14    RenderOnce, SharedString, StyleRefinement, Styled, UnderlineStyle, Window,
15};
16use herogpui_core::element_id;
17use herogpui_theme::ActiveTheme;
18
19use crate::a11y::{self, A11y as _};
20
21/// A press handler. `Arc` rather than `Box` because it is bound twice: the
22/// pointer's `on_click` and the keyboard's Enter/Space both run it.
23type OnPress = std::sync::Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
24
25/// v3's `render` — a function of the link's interactive render-props state.
26type Render = std::sync::Arc<dyn Fn(crate::util::InteractiveState) -> gpui::AnyElement + 'static>;
27
28/// The underline v3 turns on for hover and press: `decoration-[1.5px]`, with
29/// only the decoration colour differing between the two states.
30fn underline(color: Hsla) -> UnderlineStyle {
31    UnderlineStyle {
32        thickness: px(1.5),
33        color: Some(color),
34        wavy: false,
35    }
36}
37
38/// The pinned `.link__icon` slot: a centered, muted 0.75em icon. The
39/// childless default-arrow margin is deliberately not part of this wrapper.
40fn icon_slot(
41    icon: gpui::AnyElement,
42    id: ElementId,
43    is_focus_visible: bool,
44    link_color: Hsla,
45) -> gpui::AnyElement {
46    div()
47        .id(id)
48        .flex()
49        .items_center()
50        .justify_center()
51        .size(px(12.))
52        .flex_shrink_0()
53        .text_color(link_color)
54        .opacity(if is_focus_visible { 1.0 } else { 0.6 })
55        .hover(|s| s.opacity(1.0))
56        .active(|s| s.opacity(1.0))
57        .child(icon)
58        .into_any_element()
59}
60
61/// HeroUI Link.
62#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
63#[derive(IntoElement)]
64pub struct Link {
65    id: ElementId,
66    label: Option<SharedString>,
67    href: Option<String>,
68    is_disabled: bool,
69    /// `autoFocus` — take focus on the first render.
70    auto_focus: bool,
71    /// `Link.Icon` — the glyph v3 composes beside the label. `None` when the
72    /// caller composes no icon at all.
73    icon: Option<gpui::AnyElement>,
74    /// Whether the icon comes first. v3 gets this from where `Link.Icon` sits
75    /// among the link's children.
76    icon_first: bool,
77    /// `render` — draws the link's content in place of the label and icon,
78    /// handed the interactive state v3 passes its render functions.
79    render: Option<Render>,
80    on_press: Option<OnPress>,
81    /// The root box's corner radius, in place of the owning `small_radius`
82    /// helper.
83    radius: Option<Pixels>,
84    /// The `sx` slot, refined over the root style at the end of render.
85    sx: Option<Box<StyleRefinement>>,
86}
87
88impl Link {
89    /// Creates a link with the given id.
90    pub fn new(id: impl Into<ElementId>) -> Self {
91        Self {
92            id: id.into(),
93            label: None,
94            href: None,
95            is_disabled: false,
96            auto_focus: false,
97            icon: None,
98            icon_first: false,
99            render: None,
100            on_press: None,
101            radius: None,
102            sx: None,
103        }
104    }
105
106    /// `Link.Icon` (`.link__icon`) with a caller-supplied child — the
107    /// arbitrary-children path, never v3's childless `<Link.Icon />`: upstream
108    /// derives `data-default-icon` from `!children`, and the pinned `ms-1
109    /// pb-1.5` applies only to that built-in arrow, which this port does not
110    /// draw. `.link` has no gap of its own, so a custom icon sits flush.
111    pub fn icon(mut self, el: impl IntoElement) -> Self {
112        self.icon = Some(el.into_any_element());
113        self
114    }
115
116    /// Puts the icon before the label, which v3 does by ordering the children.
117    pub fn icon_first(mut self, v: bool) -> Self {
118        self.icon_first = v;
119        self
120    }
121
122    /// Sets the text label.
123    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
124        self.label = Some(label.into());
125        self
126    }
127
128    /// Sets the link target.
129    pub fn href(mut self, href: impl Into<String>) -> Self {
130        self.href = Some(href.into());
131        self
132    }
133
134    /// Sets whether the link is disabled.
135    pub fn is_disabled(mut self, v: bool) -> Self {
136        self.is_disabled = v;
137        self
138    }
139
140    /// `autoFocus` — take focus on the first render.
141    ///
142    /// A link is not otherwise a focus target here, so this also makes it one.
143    pub fn auto_focus(mut self, v: bool) -> Self {
144        self.auto_focus = v;
145        self
146    }
147
148    /// `render` — v3's render function receives the DOM props plus the link's
149    /// interactive state and renders whatever element it returns. GPUI has no
150    /// DOM props to spread onto a caller-built element, so the closure
151    /// receives the interactive half alone
152    /// (`{isHovered, isPressed, isFocused, isFocusVisible, isDisabled}`) and
153    /// draws the content; the root keeps the `href`, `onPress`, focus, and
154    /// disabled wiring either way.
155    pub fn render(
156        mut self,
157        render: impl Fn(crate::util::InteractiveState) -> gpui::AnyElement + 'static,
158    ) -> Self {
159        self.render = Some(std::sync::Arc::new(render));
160        self
161    }
162
163    /// `onPress` — extra behaviour, in addition to opening `href`.
164    pub fn on_press(
165        mut self,
166        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
167    ) -> Self {
168        self.on_press = Some(std::sync::Arc::new(handler));
169        self
170    }
171
172    /// The root box's corner radius, in place of the owning `small_radius`
173    /// helper (`.link` is `rounded-xl`). The link paints no fill of its own,
174    /// so the corner shows only against a caller-supplied background or its
175    /// focus ring. Not a v3 prop; the removed v2 `radius` prop is prohibited
176    /// and this is a per-component repository extension.
177    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
178        self.radius = Some(radius.into());
179        self
180    }
181
182    /// The one slot for caller-owned low-level styling: GPUI's styling methods
183    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
184    /// applied to the link's root element after every value the states and the
185    /// active theme chose, so they win.
186    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
187        crate::util::refine_sx(&mut self.sx, style);
188        self
189    }
190}
191
192impl RenderOnce for Link {
193    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
194        // `.link:focus-visible` is `status-focused`, and `track_focus` is what
195        // puts the link in the tab order. A disabled link must leave that order
196        // like every other disabled control in this port — `track_focus` gates
197        // on interactivity, and so does the ring — which is what
198        // `pointer-events-none` with nothing to move to amounts to here.
199        let interactive = !self.is_disabled;
200        // `focus_once` takes `cx` mutably, so it runs before the tokens.
201        let focus =
202            crate::util::tab_stop_handle(element_id::scoped(&self.id, "link-focus"), window, cx);
203        // `autoFocus` needs a focus target, and a link is only one while it is
204        // interactive: a disabled link is skipped by Tab, so it must not grab
205        // the focus on its first frame either.
206        if self.auto_focus && interactive {
207            crate::util::focus_once(
208                window,
209                cx,
210                element_id::scoped(&self.id, "link-autofocus"),
211                &focus,
212            );
213        }
214
215        // Tokens are `Copy`, so take them before the `cx`-mutating state calls
216        // below.
217        let link_color = cx.colors().link;
218        let disabled_opacity = cx.layout().disabled_opacity;
219        let hover_decoration = cx.colors().muted.alpha(0.5);
220        let pressed_decoration = cx.colors().muted;
221
222        // One hover/press slot per link, for a `render` closure. The tracking
223        // handlers cost a listener and a frame of lag; without the closure
224        // nothing observes the states, so nothing tracks them.
225        let interaction = if self.render.is_some() {
226            Some(crate::util::interaction(
227                element_id::scoped(&self.id, "link-interaction"),
228                window,
229                cx,
230            ))
231        } else {
232            None
233        };
234        if let Some(slot) = &interaction {
235            if !interactive && *slot.read(cx) != (false, false) {
236                slot.update(cx, |state, _| *state = (false, false));
237            }
238        }
239
240        // The resting corner is resolved once: the box rounds to it and the
241        // focus ring overlay has to be concentric with that same shape.
242        let link_radius = self.radius.unwrap_or_else(|| crate::util::small_radius(cx));
243
244        // RAC's `Link` renders a native `<a>`, whose role is `link`;
245        // `useLink` only adds the explicit role when the element is not an
246        // anchor. The rendered text carries no element id and so contributes
247        // no name, which is why the label is restated on the node.
248        let mut el = div()
249            .id(self.id.clone())
250            .a11y_named(a11y::Role::Link, &a11y::Name::maybe(self.label.clone()))
251            .flex()
252            .items_center()
253            .w_auto()
254            // `.link` is `font-medium text-link` — the docs' Global CSS
255            // snippet still says `font-semibold`, but the stylesheet is the
256            // contract, and the text colour never changes state.
257            .text_color(link_color)
258            .font_weight(gpui::FontWeight::MEDIUM)
259            .rounded(link_radius);
260        if interactive {
261            el = crate::util::ring_overlay_if_focused(
262                el.track_focus(&focus),
263                &focus,
264                true,
265                link_radius,
266                Vec::new(),
267                window,
268                cx,
269            );
270        }
271
272        if self.is_disabled {
273            // `.link[aria-disabled="true"]` is `status-disabled`: the disabled
274            // opacity, no pointer reach, and no tab stop.
275            el = el.opacity(disabled_opacity);
276        } else {
277            // `&:hover` draws `underline decoration-muted/50` and `&:active`
278            // takes the decoration to full `decoration-muted`; neither touches
279            // the text colour. gpui panics on a second `hover` call, so each
280            // closure owns its state's whole underline.
281            el = el
282                .cursor(crate::util::interactive_cursor(cx))
283                .hover(move |mut s: StyleRefinement| {
284                    s.text_style().underline = Some(underline(hover_decoration));
285                    s
286                })
287                .active(move |mut s: StyleRefinement| {
288                    s.text_style().underline = Some(underline(pressed_decoration));
289                    s
290                });
291        }
292
293        // v3 orders `Link.Icon` among the children, so the icon can lead or
294        // trail the label. A `render` closure replaces that content and is
295        // handed the state the slot tracked one frame ago.
296        let icon_focus_visible =
297            interactive && focus.is_focused(window) && crate::util::focus_visible(cx);
298        if let Some(render) = &self.render {
299            let (is_hovered, is_pressed) = interaction
300                .as_ref()
301                .map_or((false, false), |slot| *slot.read(cx));
302            let focused = interactive && focus.is_focused(window);
303            let state = crate::util::InteractiveState {
304                is_hovered,
305                is_pressed,
306                is_focused: focused,
307                is_focus_visible: focused && crate::util::focus_visible(cx),
308                is_disabled: self.is_disabled,
309                ..Default::default()
310            };
311            el = el.child(render(state));
312        } else {
313            // `.link` has no gap, and the pinned `ms-1 pb-1.5` belongs only
314            // to `[data-default-icon="true"]` — the built-in arrow drawn by a
315            // childless `<Link.Icon />`, which this port does not render. A
316            // caller icon sits flush against the label on either side.
317            if self.icon_first {
318                if let Some(icon) = self.icon.take() {
319                    el = el.child(icon_slot(
320                        icon,
321                        element_id::scoped(&self.id, "link-icon"),
322                        icon_focus_visible,
323                        link_color,
324                    ));
325                }
326            }
327            if let Some(label) = self.label.clone() {
328                el = el.child(label.to_string());
329            }
330            if !self.icon_first {
331                if let Some(icon) = self.icon.take() {
332                    el = el.child(icon_slot(
333                        icon,
334                        element_id::scoped(&self.id, "link-icon"),
335                        icon_focus_visible,
336                        link_color,
337                    ));
338                }
339            }
340        }
341        if let Some(slot) = &interaction {
342            el = crate::util::track_interaction(el, slot);
343        }
344
345        if !self.is_disabled {
346            let href = self.href.clone();
347            let user_click = self.on_press;
348            el = el.on_click(move |ev: &ClickEvent, window, cx| {
349                if let Some(f) = &user_click {
350                    f(ev, window, cx);
351                }
352                if let Some(url) = &href {
353                    cx.open_url(url);
354                }
355            });
356        }
357
358        el = crate::util::apply_sx(el, &self.sx);
359        if interactive {
360            el = crate::util::record_focus_bounds(el, &focus, window, cx);
361        }
362        el
363    }
364}
365
366crate::util::impl_component_styled!(Link);