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);