Make the button-shaped links actual links

"Web oficial" did nothing when clicked, and it was not alone: six places
wrapped `<pi-action-button>` in an `<a>`. That component renders a
`<button>`, so each one was a button inside a link -- invalid HTML, and
the button swallows the click. Besides "Web oficial" it covered Demo,
Repositorio, the contact CTA on every detail page, the 404's way home
and a call card's apply button.

They become one `app-link-button`, which renders a real anchor. The
click was only part of it: a link can also be opened in a new tab,
middle-clicked, copied, tabbed to and followed by a crawler.

Its look does not come from @pi/ui's button classes, and not for want of
trying: those rules are scoped to the element (`html button:not(…)` in
`_pi-button.scss`), so on an anchor they contribute nothing except
`pi-color__primary` -- a *text* colour, which painted the label violet on
a violet fill. The stylesheet reads the same `--pi-theme-button-*`
tokens the library reads instead, so a change of shell or theme still
moves these buttons and dark mode needs nothing of its own.

Verified: the anchor reports href, target and a real navigation to the
external site; no `<button>` inside an `<a>` remains on the page; and
the rendered pill matches the header's button in radius, gradient fill,
white text and weight, in both themes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-30 19:07:15 +02:00
parent e2e90d5512
commit aea8f5675d
7 changed files with 202 additions and 58 deletions

View File

@@ -16,6 +16,7 @@ import type { Copy } from '../core/copy';
import type { Locale } from '../core/api';
import type { PublicCall } from '../core/api.types';
import { TechnologyChip } from './technology-chip';
import { LinkButton } from './link-button';
@Component({
selector: 'app-call-card',
@@ -32,6 +33,7 @@ import { TechnologyChip } from './technology-chip';
PiTextColorDirective,
PiTextWeightBoldDirective,
TechnologyChip,
LinkButton,
],
template: `
<pi-card
@@ -96,15 +98,13 @@ import { TechnologyChip } from './technology-chip';
<!-- To the call's own page and its form when it has one; to the
landing's general form otherwise. -->
@if (call().path; as path) {
<a class="landing-card__button-link" [href]="path">
<pi-action-button
<app-link-button
class="landing-card__button-link"
size="small"
color="primary"
appearance="outline"
[hasIcon]="true"
[href]="path"
[text]="copy().labels.apply"
/>
</a>
} @else {
<pi-action-button
size="small"

View File

@@ -4,11 +4,12 @@ import {
computed,
input,
} from '@angular/core';
import { PiActionButtonComponent, PiTextColorDirective } from '@pi/ui';
import { PiTextColorDirective } from '@pi/ui';
import type { Locale } from '../core/api';
import type { Copy } from '../core/copy';
import { homeHref } from '../core/routes';
import { LinkButton } from './link-button';
/**
* "Shall we talk?" at the end of a page's side column, so no page is a dead
@@ -17,20 +18,18 @@ import { homeHref } from '../core/routes';
@Component({
selector: 'app-contact-cta',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiActionButtonComponent, PiTextColorDirective],
imports: [PiTextColorDirective, LinkButton],
template: `
<div class="landing-card contact-cta">
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
<!-- An anchor around the library's button, which renders a <button>:
a real link, so it works before hydration and opens in a new tab. -->
<a class="contact-cta__link" [href]="href()">
<pi-action-button
color="primary"
<app-link-button
class="contact-cta__link"
[href]="href()"
[text]="copy().nav.contact"
[hasIcon]="true"
/>
</a>
</div>
`,
styles: `

View File

@@ -0,0 +1,79 @@
:host {
display: inline-flex;
}
// `@pi/ui` scopes its button rules to the element (`html button:not(…)` in
// `_pi-button.scss`), so an anchor cannot inherit them. Every value below is
// still the library's: these are the same `--pi-theme-button-*` custom
// properties its own rules read, so switching shell or theme moves this button
// too, and dark mode needs nothing of its own.
.link-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-width: 5.5rem;
border: none;
border-radius: var(--pi-theme-button-radius);
line-height: 100%;
text-decoration: none;
letter-spacing: var(--pi-theme-button-letter-spacing);
text-transform: var(--pi-theme-button-text-transform);
font-weight: var(--pi-theme-button-font-weight);
transition: var(--pi-theme-button-transition);
cursor: pointer;
svg {
flex: none;
width: 0.375rem;
height: 0.5625rem;
}
&:focus-visible {
outline: 2px solid var(--pi-color-primary);
outline-offset: 3px;
}
}
.link-button--medium,
.link-button--large {
min-height: var(--pi-theme-button-height);
padding: var(--pi-spacing-xx-small) var(--pi-spacing-x-large);
}
.link-button--small {
min-width: 4.5rem;
padding: var(--pi-spacing-xxxx-small) var(--pi-spacing-medium);
font-size: 0.8125rem;
}
// Filled. The theme may inject a gradient over the flat colour, exactly as it
// does for a real button.
.link-button--fill {
color: var(--pi-color-white);
box-shadow: var(--pi-theme-button-shadow);
&.link-button--primary {
background-color: var(--pi-color-primary);
background-image: var(--pi-theme-button-fill-primary, none);
}
&.link-button--grey {
background-color: var(--pi-color-grey);
}
}
// Outline. The colour is the border and the text; the surface stays clear.
.link-button--outline {
background: none;
&.link-button--primary {
border: 1px solid var(--pi-color-primary);
color: var(--pi-color-primary);
}
&.link-button--grey {
border: 1px solid var(--pi-color-grey-200);
color: var(--pi-color-grey);
}
}

View File

@@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
/**
* A link that looks like the library's action button.
*
* `pi-action-button` renders a `<button>`, so wrapping one in an `<a>` puts a
* button inside a link: invalid HTML, and the button swallows the click, so
* nothing navigates. That is what stopped "Web oficial" working, and the same
* pattern sat in five other places.
*
* Making it an anchor is not only about the click. A real link can be opened
* in a new tab, middle-clicked, copied, reached by keyboard and followed by a
* crawler; a button can do none of that.
*
* The library's own button classes are deliberately NOT reused. Its rules are
* scoped to the element — `html button:not(…)` — so on an anchor they
* contribute nothing except `pi-color__primary`, which is a *text* colour and
* actively fights the white a filled button needs. The stylesheet beside this
* file reads the same `--pi-theme-button-*` tokens the library reads instead,
* so a change of shell or theme still reaches this button.
*/
type Size = 'small' | 'medium' | 'large';
type Tone = 'primary' | 'grey';
@Component({
selector: 'app-link-button',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './link-button.scss',
template: `
<a
[class]="classes()"
[href]="href()"
[attr.target]="external() ? '_blank' : null"
[attr.rel]="external() ? 'noopener noreferrer' : null"
>
<span>{{ text() }}</span>
<svg
viewBox="0 0 6 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g>
<path
d="M1 1C4.5 4 5 4.38484 5 4.5C5 4.61516 4.5 5 1 8"
stroke="currentColor"
stroke-width="1"
/>
</g>
<g>
<path d="M4.5 4.5H0" stroke="currentColor" stroke-width="1" />
</g>
</svg>
</a>
`,
})
export class LinkButton {
readonly href = input.required<string>();
readonly text = input.required<string>();
readonly size = input<Size>('medium');
readonly color = input<Tone>('primary');
readonly appearance = input<'fill' | 'outline'>('fill');
/** Opens in a new tab, for addresses that are not ours. */
readonly external = input(false);
protected readonly classes = computed(() => [
'link-button',
`link-button--${this.size()}`,
`link-button--${this.color()}`,
`link-button--${this.appearance()}`,
]);
}

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { LinkButton } from './link-button';
import {
PiActionButtonComponent,
PiIconComponent,
PiTextColorDirective,
PiTextMediumDirective,
@@ -26,12 +26,12 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiIconComponent,
PiActionButtonComponent,
PiTextXLargeDirective,
PiTextMediumDirective,
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
LinkButton,
],
styleUrl: './not-found.scss',
template: `
@@ -47,14 +47,12 @@ import {
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
</p>
<a class="not-found__home" href="/">
<pi-action-button
<app-link-button
class="not-found__home"
size="large"
color="primary"
href="/"
text="Ir al inicio"
[hasIcon]="true"
/>
</a>
<p s pi-color color="grey" class="not-found__note">
Si has llegado desde un enlace nuestro, cuéntanoslo.

View File

@@ -5,7 +5,6 @@ import {
input,
} from '@angular/core';
import {
PiActionButtonComponent,
PiAvatarComponent,
PiChipComponent,
PiChipStatusComponent,
@@ -21,6 +20,7 @@ import { ApiImage } from './api-image';
import { ContactCta } from './contact-cta';
import { PROJECT_STATUS_TONES } from './project-card';
import { TechnologyChip } from './technology-chip';
import { LinkButton } from './link-button';
/**
* A project on its own address: what it is, why it was built the way it was,
@@ -35,7 +35,6 @@ import { TechnologyChip } from './technology-chip';
imports: [
ApiImage,
ContactCta,
PiActionButtonComponent,
PiAvatarComponent,
PiChipComponent,
PiChipStatusComponent,
@@ -43,6 +42,7 @@ import { TechnologyChip } from './technology-chip';
PiTextLargeDirective,
PiTextWeightBoldDirective,
TechnologyChip,
LinkButton,
],
styleUrl: './project-detail.scss',
template: `
@@ -92,23 +92,20 @@ import { TechnologyChip } from './technology-chip';
@if (project().demo_url || repositoryUrl()) {
<div class="content-hero__actions">
@if (project().demo_url; as demo) {
<a [href]="demo" target="_blank" rel="noopener">
<pi-action-button
color="primary"
<app-link-button
[href]="demo"
[text]="copy().labels.demo"
[hasIcon]="true"
[external]="true"
/>
</a>
}
@if (repositoryUrl(); as repository) {
<a [href]="repository" target="_blank" rel="noopener">
<pi-action-button
appearance="outline"
<app-link-button
color="grey"
appearance="outline"
[href]="repository"
[text]="copy().labels.repository"
[hasIcon]="true"
[external]="true"
/>
</a>
}
</div>
}

View File

@@ -5,7 +5,6 @@ import {
input,
} from '@angular/core';
import {
PiActionButtonComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
@@ -15,6 +14,7 @@ import type { Locale } from '../core/api';
import type { PublicTechnology } from '../core/api.types';
import type { Copy } from '../core/copy';
import { listingPath } from '../core/routes';
import { LinkButton } from './link-button';
import { ApiImage } from './api-image';
import { ContactCta } from './contact-cta';
@@ -36,10 +36,10 @@ import { ContactCta } from './contact-cta';
imports: [
ApiImage,
ContactCta,
PiActionButtonComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
LinkButton,
],
styleUrl: './technology-detail.scss',
template: `
@@ -75,14 +75,12 @@ import { ContactCta } from './contact-cta';
@if (technology().website; as website) {
<p class="content-hero__actions">
<a [href]="website" target="_blank" rel="noopener noreferrer">
<pi-action-button
<app-link-button
size="large"
color="primary"
[href]="website"
[text]="copy().technology.website"
[hasIcon]="true"
[external]="true"
/>
</a>
</p>
}
</div>