/**
* AgnosticUI v2 Link - Canonical Implementation
*
* A semantic, accessible wrapper around the native HTML element that provides
* consistent styling, variants, and states while preserving native browser accessibility.
*
* Version: 2.0.0-dev
* Last Updated: 2025-11-18
* API Compatibility: 2.x
*/
import { LitElement, html, css } from 'lit';
import { property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
// Props interface
export interface LinkProps {
href?: string;
variant?: 'primary' | 'success' | 'warning' | 'danger' | 'monochrome' | '';
isButton?: boolean;
buttonSize?: 'x-sm' | 'sm' | 'md' | 'lg' | 'xl';
buttonShape?: 'capsule' | 'rounded' | 'circle' | 'square' | 'rounded-square' | '';
buttonBordered?: boolean;
external?: boolean;
disabled?: boolean;
ariaLabel?: string;
onClick?: (event: MouseEvent) => void;
onFocus?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
}
/**
* AgLink - Foundation link component with full accessibility
*
* A semantic anchor element that supports multiple variants and states
* while maintaining native browser accessibility features.
*
* Features:
* - Semantic element foundation
* - Keyboard navigation (browser default)
* - Visual variants (primary, success, warning, danger, monochrome)
* - Button-style links with size, shape, and bordered options
* - External link support
* - Disabled state handling
*/
export class AgLink extends LitElement implements LinkProps {
static styles = css`
/* MINIMALIST & THEMEABLE - Styling via --ag-* design tokens */
:host {
display: inline;
}
a {
/* Base link styles */
font-size: var(--ag-font-size-sm);
color: var(--ag-primary-text);
text-decoration: underline;
cursor: pointer;
transition-property: color, background-color, border-color, text-decoration-color;
transition-duration: var(--ag-motion-medium);
}
a:hover {
color: var(--ag-primary-text);
opacity: 0.85;
}
a:focus-visible {
outline: var(--ag-focus-width) solid rgba(var(--ag-focus), 0.5);
outline-offset: var(--ag-focus-offset);
transition: outline var(--ag-motion-medium) ease;
}
/* Variant styles for standard links */
:host([variant="primary"]) a {
color: var(--ag-primary-text);
font-weight: 600;
}
:host([variant="primary"]) a:hover {
color: var(--ag-primary-text);
opacity: 0.85;
}
:host([variant="success"]) a {
color: var(--ag-success-text);
font-weight: 600;
}
:host([variant="success"]) a:hover {
color: var(--ag-success-text);
opacity: 0.85;
}
:host([variant="warning"]) a {
font-weight: 600;
color: var(--ag-warning-text);
}
:host([variant="warning"]) a:hover {
color: var(--ag-warning-text);
opacity: 0.85;
}
:host([variant="danger"]) a {
font-weight: 600;
color: var(--ag-danger-text);
}
:host([variant="danger"]) a:hover {
color: var(--ag-danger-text);
opacity: 0.85;
}
:host([variant="monochrome"]) a {
color: var(--ag-text-primary);
font-weight: 600;
}
:host([variant="monochrome"]) a:hover {
color: var(--ag-text-primary);
opacity: 0.85;
}
/* Button-style link base */
:host([isButton]) a {
display: inline-flex;
align-items: center;
justify-content: center;
padding: calc(2px + var(--ag-space-2)) var(--ag-space-4);
background: var(--ag-primary);
color: var(--ag-white);
text-decoration: none;
border: var(--ag-border-width-1) solid transparent;
font-size: var(--ag-font-size-sm);
line-height: 1;
gap: var(--ag-space-1);
}
:host([isButton]) a:hover {
background: var(--ag-primary-dark);
color: var(--ag-white);
}
/* Button variant styles */
:host([isButton][variant="primary"]) a {
background: var(--ag-primary);
color: var(--ag-white);
}
:host([isButton][variant="primary"]) a:hover {
background: var(--ag-primary-dark);
}
:host([isButton][variant="success"]) a {
background: var(--ag-success);
color: var(--ag-white);
}
:host([isButton][variant="success"]) a:hover {
background: var(--ag-success-dark);
}
:host([isButton][variant="warning"]) a {
background: var(--ag-warning);
color: var(--ag-neutral-900);
}
:host([isButton][variant="warning"]) a:hover {
background: var(--ag-warning-dark);
}
:host([isButton][variant="danger"]) a {
background: var(--ag-danger);
color: var(--ag-white);
}
:host([isButton][variant="danger"]) a:hover {
background: var(--ag-danger-dark);
}
:host([isButton][variant="monochrome"]) a {
background: var(--ag-background-primary-inverted);
color: var(--ag-text-primary-inverted);
font-weight: 400;
}
:host([isButton][variant="monochrome"]) a:hover {
background: var(--ag-background-secondary-inverted);
}
/* Button size variants */
:host([isButton][buttonSize="x-sm"]) a {
font-size: calc(var(--ag-font-size-base) - 0.375rem);
padding: var(--ag-space-1) var(--ag-space-2);
}
:host([isButton][buttonSize="sm"]) a {
font-size: var(--ag-font-size-xs);
padding: var(--ag-space-2) var(--ag-space-3);
}
:host([isButton][buttonSize="md"]) a {
font-size: var(--ag-font-size-sm);
padding: calc(2px + var(--ag-space-2)) var(--ag-space-4);
}
:host([isButton][buttonSize="lg"]) a {
font-size: var(--ag-font-size-base);
padding: var(--ag-space-3) var(--ag-space-5);
}
:host([isButton][buttonSize="xl"]) a {
font-size: var(--ag-font-size-md);
padding: var(--ag-space-3) var(--ag-space-6);
}
/* Button shape variants */
:host([isButton][buttonShape="capsule"]) a {
border-radius: var(--ag-radius-full);
padding-inline-start: var(--ag-space-5);
padding-inline-end: var(--ag-space-5);
}
:host([isButton][buttonShape="rounded"]) a {
border-radius: var(--ag-radius-md);
}
:host([isButton][buttonShape="circle"]) a {
border-radius: 50%;
width: var(--ag-space-10);
height: var(--ag-space-10);
padding: 0;
}
:host([isButton][buttonShape="square"]) a {
border-radius: 0;
width: var(--ag-space-10);
height: var(--ag-space-10);
padding: 0;
}
:host([isButton][buttonShape="rounded-square"]) a {
border-radius: var(--ag-radius-md);
width: var(--ag-space-10);
height: var(--ag-space-10);
padding: 0;
}
/* Button bordered variant */
:host([isButton][buttonBordered]) a {
background: transparent;
border: 1px solid var(--ag-neutral-500);
color: inherit;
}
:host([isButton][buttonBordered][variant="primary"]) a {
color: var(--ag-primary-text);
border-color: var(--ag-primary-text);
background: transparent;
}
:host([isButton][buttonBordered][variant="primary"]) a:hover {
background: var(--ag-primary);
color: var(--ag-white);
}
:host([isButton][buttonBordered][variant="success"]) a {
color: var(--ag-success-text);
border-color: var(--ag-success-text);
background: transparent;
}
:host([isButton][buttonBordered][variant="success"]) a:hover {
background: var(--ag-success);
color: var(--ag-white);
}
:host([isButton][buttonBordered][variant="warning"]) a {
color: var(--ag-warning-text);
border-color: var(--ag-warning-text);
background: transparent;
}
:host([isButton][buttonBordered][variant="warning"]) a:hover {
background: var(--ag-warning);
color: var(--ag-neutral-900);
}
:host([isButton][buttonBordered][variant="danger"]) a {
color: var(--ag-danger-text);
border-color: var(--ag-danger-text);
background: transparent;
}
:host([isButton][buttonBordered][variant="danger"]) a:hover {
background: var(--ag-danger);
color: var(--ag-white);
}
:host([isButton][buttonBordered][variant="monochrome"]) a {
color: var(--ag-text-primary);
border-color: var(--ag-text-primary);
background: transparent;
}
:host([isButton][buttonBordered][variant="monochrome"]) a:hover {
background: var(--ag-background-primary-inverted);
color: var(--ag-text-primary-inverted);
}
/* Disabled state */
:host([disabled]) {
cursor: not-allowed;
}
:host([disabled]) a,
:host([disabled]) span {
color: var(--ag-text-muted);
cursor: not-allowed;
opacity: 0.6;
text-decoration: none;
pointer-events: none;
}
:host([disabled][isButton]) a,
:host([disabled][isButton]) span {
background: var(--ag-background-disabled);
color: var(--ag-text-muted);
}
`;
/**
* URL the link points to
*/
@property({ type: String })
declare href: string;
/**
* Visual variant for styling hooks
*/
@property({ type: String, reflect: true })
declare variant: 'primary' | 'success' | 'warning' | 'danger' | 'monochrome' | '';
/**
* Styles the link to look like a button
*/
@property({ type: Boolean, reflect: true })
declare isButton: boolean;
/**
* Size variant for button-style links (only applies when isButton is true)
*/
@property({ type: String, reflect: true })
declare buttonSize: 'x-sm' | 'sm' | 'md' | 'lg' | 'xl';
/**
* Shape variant for button-style links (only applies when isButton is true)
*/
@property({ type: String, reflect: true })
declare buttonShape: 'capsule' | 'rounded' | 'circle' | 'square' | 'rounded-square' | '';
/**
* Bordered style for button-style links (only applies when isButton is true)
*/
@property({ type: Boolean, reflect: true })
declare buttonBordered: boolean;
/**
* Indicates external link (adds rel and target attributes)
*/
@property({ type: Boolean, reflect: true })
declare external: boolean;
/**
* Disabled state - visually and functionally disables the link
*/
@property({ type: Boolean, reflect: true })
declare disabled: boolean;
/**
* ARIA label for accessibility
*/
@property({ type: String, reflect: true, attribute: 'aria-label' })
declare ariaLabel: string;
@property({ attribute: false })
declare onClick?: (event: MouseEvent) => void;
@property({ attribute: false })
declare onFocus?: (event: FocusEvent) => void;
@property({ attribute: false })
declare onBlur?: (event: FocusEvent) => void;
constructor() {
super();
this.href = '';
this.variant = '';
this.isButton = false;
this.buttonSize = 'md';
this.buttonShape = '';
this.buttonBordered = false;
this.external = false;
this.disabled = false;
this.ariaLabel = '';
}
/**
* Lit lifecycle: called when properties change
* Used to validate that button-specific props are only used with isButton
*/
protected willUpdate(changedProperties: Map) {
super.willUpdate(changedProperties);
// Validation: button props should only be used with isButton
// In development, the CSS will still work but props won't have visual effect
if (!this.isButton) {
if (changedProperties.has('buttonSize') && this.buttonSize !== 'md') {
// buttonSize ignored when isButton is false
}
if (changedProperties.has('buttonShape') && this.buttonShape !== '') {
// buttonShape ignored when isButton is false
}
if (changedProperties.has('buttonBordered') && this.buttonBordered) {
// buttonBordered ignored when isButton is false
}
}
}
private _handleClick(event: MouseEvent) {
if (this.disabled) {
event.preventDefault();
return;
}
// Invoke user-defined onClick handler if provided
if (this.onClick) {
this.onClick(event);
}
}
private _handleFocus(event: FocusEvent) {
// Call user-defined onFocus handler if provided
if (this.onFocus) {
this.onFocus(event);
}
// Re-dispatch native platform events for consumers
const focusEvent = new FocusEvent('focus', {
bubbles: true,
composed: true
});
this.dispatchEvent(focusEvent);
}
private _handleBlur(event: FocusEvent) {
// Call prop handler for consumer if it's listening
if (this.onBlur) {
this.onBlur(event);
}
// Forward blur event from internal anchor to custom element
const blurEvent = new FocusEvent('blur', {
bubbles: true,
composed: true
});
this.dispatchEvent(blurEvent);
}
/**
* Focus the internal anchor element
*/
focus() {
const anchor = this.shadowRoot?.querySelector('a');
if (anchor) {
anchor.focus();
}
}
/**
* Blur the internal anchor element
*/
blur() {
const anchor = this.shadowRoot?.querySelector('a');
if (anchor) {
anchor.blur();
}
}
render() {
// When disabled, render a span instead of anchor to remove interactivity
if (this.disabled) {
return html`
`;
}
return html`
`;
}
}