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:
@@ -16,6 +16,7 @@ import type { Copy } from '../core/copy';
|
|||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import type { PublicCall } from '../core/api.types';
|
import type { PublicCall } from '../core/api.types';
|
||||||
import { TechnologyChip } from './technology-chip';
|
import { TechnologyChip } from './technology-chip';
|
||||||
|
import { LinkButton } from './link-button';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-call-card',
|
selector: 'app-call-card',
|
||||||
@@ -32,6 +33,7 @@ import { TechnologyChip } from './technology-chip';
|
|||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
TechnologyChip,
|
TechnologyChip,
|
||||||
|
LinkButton,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<pi-card
|
<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
|
<!-- To the call's own page and its form when it has one; to the
|
||||||
landing's general form otherwise. -->
|
landing's general form otherwise. -->
|
||||||
@if (call().path; as path) {
|
@if (call().path; as path) {
|
||||||
<a class="landing-card__button-link" [href]="path">
|
<app-link-button
|
||||||
<pi-action-button
|
class="landing-card__button-link"
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
|
||||||
appearance="outline"
|
appearance="outline"
|
||||||
[hasIcon]="true"
|
[href]="path"
|
||||||
[text]="copy().labels.apply"
|
[text]="copy().labels.apply"
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
} @else {
|
} @else {
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PiActionButtonComponent, PiTextColorDirective } from '@pi/ui';
|
import { PiTextColorDirective } from '@pi/ui';
|
||||||
|
|
||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
import { homeHref } from '../core/routes';
|
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
|
* "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({
|
@Component({
|
||||||
selector: 'app-contact-cta',
|
selector: 'app-contact-cta',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [PiActionButtonComponent, PiTextColorDirective],
|
imports: [PiTextColorDirective, LinkButton],
|
||||||
template: `
|
template: `
|
||||||
<div class="landing-card contact-cta">
|
<div class="landing-card contact-cta">
|
||||||
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
|
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
|
||||||
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
|
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
|
||||||
<!-- An anchor around the library's button, which renders a <button>:
|
<!-- 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 real link, so it works before hydration and opens in a new tab. -->
|
||||||
<a class="contact-cta__link" [href]="href()">
|
<app-link-button
|
||||||
<pi-action-button
|
class="contact-cta__link"
|
||||||
color="primary"
|
[href]="href()"
|
||||||
[text]="copy().nav.contact"
|
[text]="copy().nav.contact"
|
||||||
[hasIcon]="true"
|
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
|
|||||||
79
landing/src/app/ui/link-button.scss
Normal file
79
landing/src/app/ui/link-button.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
landing/src/app/ui/link-button.ts
Normal file
73
landing/src/app/ui/link-button.ts
Normal 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()}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
import { LinkButton } from './link-button';
|
||||||
import {
|
import {
|
||||||
PiActionButtonComponent,
|
|
||||||
PiIconComponent,
|
PiIconComponent,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextMediumDirective,
|
PiTextMediumDirective,
|
||||||
@@ -26,12 +26,12 @@ import {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
PiIconComponent,
|
PiIconComponent,
|
||||||
PiActionButtonComponent,
|
|
||||||
PiTextXLargeDirective,
|
PiTextXLargeDirective,
|
||||||
PiTextMediumDirective,
|
PiTextMediumDirective,
|
||||||
PiTextSmallDirective,
|
PiTextSmallDirective,
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
LinkButton,
|
||||||
],
|
],
|
||||||
styleUrl: './not-found.scss',
|
styleUrl: './not-found.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -47,14 +47,12 @@ import {
|
|||||||
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
|
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a class="not-found__home" href="/">
|
<app-link-button
|
||||||
<pi-action-button
|
class="not-found__home"
|
||||||
size="large"
|
size="large"
|
||||||
color="primary"
|
href="/"
|
||||||
text="Ir al inicio"
|
text="Ir al inicio"
|
||||||
[hasIcon]="true"
|
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
|
|
||||||
<p s pi-color color="grey" class="not-found__note">
|
<p s pi-color color="grey" class="not-found__note">
|
||||||
Si has llegado desde un enlace nuestro, cuéntanoslo.
|
Si has llegado desde un enlace nuestro, cuéntanoslo.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
input,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
PiActionButtonComponent,
|
|
||||||
PiAvatarComponent,
|
PiAvatarComponent,
|
||||||
PiChipComponent,
|
PiChipComponent,
|
||||||
PiChipStatusComponent,
|
PiChipStatusComponent,
|
||||||
@@ -21,6 +20,7 @@ import { ApiImage } from './api-image';
|
|||||||
import { ContactCta } from './contact-cta';
|
import { ContactCta } from './contact-cta';
|
||||||
import { PROJECT_STATUS_TONES } from './project-card';
|
import { PROJECT_STATUS_TONES } from './project-card';
|
||||||
import { TechnologyChip } from './technology-chip';
|
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,
|
* 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: [
|
imports: [
|
||||||
ApiImage,
|
ApiImage,
|
||||||
ContactCta,
|
ContactCta,
|
||||||
PiActionButtonComponent,
|
|
||||||
PiAvatarComponent,
|
PiAvatarComponent,
|
||||||
PiChipComponent,
|
PiChipComponent,
|
||||||
PiChipStatusComponent,
|
PiChipStatusComponent,
|
||||||
@@ -43,6 +42,7 @@ import { TechnologyChip } from './technology-chip';
|
|||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
TechnologyChip,
|
TechnologyChip,
|
||||||
|
LinkButton,
|
||||||
],
|
],
|
||||||
styleUrl: './project-detail.scss',
|
styleUrl: './project-detail.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -92,23 +92,20 @@ import { TechnologyChip } from './technology-chip';
|
|||||||
@if (project().demo_url || repositoryUrl()) {
|
@if (project().demo_url || repositoryUrl()) {
|
||||||
<div class="content-hero__actions">
|
<div class="content-hero__actions">
|
||||||
@if (project().demo_url; as demo) {
|
@if (project().demo_url; as demo) {
|
||||||
<a [href]="demo" target="_blank" rel="noopener">
|
<app-link-button
|
||||||
<pi-action-button
|
[href]="demo"
|
||||||
color="primary"
|
|
||||||
[text]="copy().labels.demo"
|
[text]="copy().labels.demo"
|
||||||
[hasIcon]="true"
|
[external]="true"
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
@if (repositoryUrl(); as repository) {
|
@if (repositoryUrl(); as repository) {
|
||||||
<a [href]="repository" target="_blank" rel="noopener">
|
<app-link-button
|
||||||
<pi-action-button
|
|
||||||
appearance="outline"
|
|
||||||
color="grey"
|
color="grey"
|
||||||
|
appearance="outline"
|
||||||
|
[href]="repository"
|
||||||
[text]="copy().labels.repository"
|
[text]="copy().labels.repository"
|
||||||
[hasIcon]="true"
|
[external]="true"
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
input,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
PiActionButtonComponent,
|
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
@@ -15,6 +14,7 @@ import type { Locale } from '../core/api';
|
|||||||
import type { PublicTechnology } from '../core/api.types';
|
import type { PublicTechnology } from '../core/api.types';
|
||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
import { listingPath } from '../core/routes';
|
import { listingPath } from '../core/routes';
|
||||||
|
import { LinkButton } from './link-button';
|
||||||
import { ApiImage } from './api-image';
|
import { ApiImage } from './api-image';
|
||||||
import { ContactCta } from './contact-cta';
|
import { ContactCta } from './contact-cta';
|
||||||
|
|
||||||
@@ -36,10 +36,10 @@ import { ContactCta } from './contact-cta';
|
|||||||
imports: [
|
imports: [
|
||||||
ApiImage,
|
ApiImage,
|
||||||
ContactCta,
|
ContactCta,
|
||||||
PiActionButtonComponent,
|
|
||||||
PiTextColorDirective,
|
PiTextColorDirective,
|
||||||
PiTextLargeDirective,
|
PiTextLargeDirective,
|
||||||
PiTextWeightBoldDirective,
|
PiTextWeightBoldDirective,
|
||||||
|
LinkButton,
|
||||||
],
|
],
|
||||||
styleUrl: './technology-detail.scss',
|
styleUrl: './technology-detail.scss',
|
||||||
template: `
|
template: `
|
||||||
@@ -75,14 +75,12 @@ import { ContactCta } from './contact-cta';
|
|||||||
|
|
||||||
@if (technology().website; as website) {
|
@if (technology().website; as website) {
|
||||||
<p class="content-hero__actions">
|
<p class="content-hero__actions">
|
||||||
<a [href]="website" target="_blank" rel="noopener noreferrer">
|
<app-link-button
|
||||||
<pi-action-button
|
|
||||||
size="large"
|
size="large"
|
||||||
color="primary"
|
[href]="website"
|
||||||
[text]="copy().technology.website"
|
[text]="copy().technology.website"
|
||||||
[hasIcon]="true"
|
[external]="true"
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user