import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
// Import base component and utils
import { USWDSBaseComponent } from '@uswds-wc/core';
// Import official USWDS compiled CSS
/**
* USA Link Web Component
*
* A simple, accessible USWDS link implementation as a custom element.
* Provides consistent link styling with support for external links,
* different visual variants, and proper accessibility attributes.
* Uses official USWDS classes and styling with minimal custom code.
*
* @element usa-link
* @fires link-click - Dispatched when the link is clicked
*
* @see README.mdx - Complete API documentation, usage examples, and implementation notes
* @see CHANGELOG.mdx - Component version history and breaking changes
* @see TESTING.mdx - Testing documentation and coverage reports
*
* @uswds-css-reference https://github.com/uswds/uswds/tree/develop/packages/usa-link/src/styles/_usa-link.scss
* @uswds-docs https://designsystem.digital.gov/components/link/
* @uswds-guidance https://designsystem.digital.gov/components/link/#guidance
* @uswds-accessibility https://designsystem.digital.gov/components/link/#accessibility
*/
@customElement('usa-link')
export class USALink extends USWDSBaseComponent {
// No shadow DOM styles since we use light DOM
// Store bound handler for proper cleanup
private boundHandleLinkClick = this.handleLinkClick.bind(this);
@property({ type: String })
href = '';
@property({ type: String })
text = '';
@property({ type: String })
target = '';
@property({ type: String })
rel = '';
@property({ type: String })
variant: 'default' | 'external' | 'alt' | 'unstyled' = 'default';
@property({ type: Boolean, reflect: true })
external = false;
@property({ type: Boolean, reflect: true })
unstyled = false;
@property({ type: String, attribute: 'aria-label' })
override ariaLabel = '';
@property({ type: String })
download = '';
override connectedCallback() {
super.connectedCallback();
// Set web component managed flag to prevent USWDS auto-initialization conflicts
this.setAttribute('data-web-component-managed', 'true');
this.debug('Link component connected', { href: this.href, variant: this.variant });
}
override firstUpdated(changedProperties: Map) {
// ARCHITECTURE: Script Tag Pattern
// USWDS is loaded globally via script tag in .storybook/preview-head.html
// Components just render HTML - USWDS enhances automatically via window.USWDS
super.firstUpdated(changedProperties);
this.setupEventListeners();
}
private setupEventListeners() {
const anchor = this.querySelector('a');
if (anchor) {
anchor.addEventListener('click', this.boundHandleLinkClick);
}
}
private handleLinkClick(event: Event) {
const isExternal = this.external || this.isExternalLink(this.href);
this.dispatchEvent(
new CustomEvent('link-click', {
detail: {
href: this.href,
external: isExternal,
target: isExternal ? '_blank' : this.target,
event: event,
},
bubbles: true,
composed: true,
})
);
}
private isExternalLink(href: string): boolean {
if (!href) return false;
// Check if it's a different domain
if (href.startsWith('http://') || href.startsWith('https://')) {
try {
const url = new URL(href);
return url.hostname !== window.location.hostname;
} catch {
return false;
}
}
return false;
}
private getLinkClasses(): string {
const classes: string[] = [];
// Base link class - always add unless explicitly unstyled
if (!this.unstyled && this.variant !== 'unstyled') {
classes.push('usa-link');
}
// Variant classes
if (this.external || this.variant === 'external' || this.isExternalLink(this.href)) {
classes.push('usa-link--external');
}
if (this.variant === 'alt') {
classes.push('usa-link--alt');
}
return classes.join(' ');
}
private getRel(): string {
let relValue = this.rel;
// Auto-add security attributes for external links
if (this.external || this.variant === 'external' || this.isExternalLink(this.href)) {
const relParts = relValue ? relValue.split(' ') : [];
if (!relParts.includes('noopener')) {
relParts.push('noopener');
}
if (!relParts.includes('noreferrer')) {
relParts.push('noreferrer');
}
relValue = relParts.join(' ');
}
return relValue;
}
private getTarget(): string {
// Auto-set target for external links if not specified
if (this.target) {
return this.target;
}
if (this.external || this.variant === 'external' || this.isExternalLink(this.href)) {
return '_blank';
}
return '';
}
override disconnectedCallback() {
const anchor = this.querySelector('a');
if (anchor) {
anchor.removeEventListener('click', this.boundHandleLinkClick);
}
super.disconnectedCallback();
}
override render() {
// Debug logging available with ?debug=true
const linkClasses = this.getLinkClasses();
const target = this.getTarget();
const rel = this.getRel();
// Use text property if set, otherwise content will be moved from children
return html`
${this.text}
`;
}
override updated(changedProperties: Map) {
super.updated(changedProperties);
// Only move children if we're not using the text property
// This allows both usage patterns:
// (attribute-based)
// Click me (slot-based)
if (!this.text) {
this.moveChildrenToElement('a');
}
}
// Public API methods
override click() {
const link = this.querySelector('a');
if (link) {
link.click();
}
}
override focus() {
const link = this.querySelector('a');
if (link) {
link.focus();
}
}
override blur() {
const link = this.querySelector('a');
if (link) {
link.blur();
}
}
}