diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 91021367..97b6d798 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -37,6 +37,7 @@ module.exports = { "table", "split-button", "datepicker", + "link", ], ], }, diff --git a/custom-elements-manifest.config.mjs b/custom-elements-manifest.config.mjs index 510aff2c..b34bd094 100644 --- a/custom-elements-manifest.config.mjs +++ b/custom-elements-manifest.config.mjs @@ -2,7 +2,12 @@ import { parse } from "comment-parser"; export default { globs: ["src/components/**/!(*.test|*.stories).ts"], - exclude: ["src/**/*.css", "src/**/*.constant.ts","src/**/*/*.types.ts","src/components/icon/icon-list.ts"], + exclude: [ + "src/**/*.css", + "src/**/*.constant.ts", + "src/**/*/*.types.ts", + "src/components/icon/icon-list.ts", + ], outdir: "dist/", dev: false, watch: false, diff --git a/src/baklava.ts b/src/baklava.ts index bbaddfb5..79a24bbf 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -1,40 +1,41 @@ -export { default as BlAccordionGroup } from "./components/accordion-group/bl-accordion-group"; export { default as BlAccordion } from "./components/accordion-group/accordion/bl-accordion"; +export { default as BlAccordionGroup } from "./components/accordion-group/bl-accordion-group"; export { default as BlAlert } from "./components/alert/bl-alert"; export { default as BlBadge } from "./components/badge/bl-badge"; export { default as BlButton } from "./components/button/bl-button"; +export { default as BlCalendar } from "./components/calendar/bl-calendar"; export { default as BlCheckboxGroup } from "./components/checkbox-group/bl-checkbox-group"; export { default as BlCheckbox } from "./components/checkbox-group/checkbox/bl-checkbox"; +export { default as BlDatepicker } from "./components/datepicker/bl-datepicker"; export { default as BlDialog } from "./components/dialog/bl-dialog"; export { default as BlDrawer } from "./components/drawer/bl-drawer"; +export { default as BlDropdown } from "./components/dropdown/bl-dropdown"; +export { default as BlDropdownGroup } from "./components/dropdown/group/bl-dropdown-group"; +export { default as BlDropdownItem } from "./components/dropdown/item/bl-dropdown-item"; export { default as BlIcon } from "./components/icon/bl-icon"; export { default as BlInput } from "./components/input/bl-input"; +export { default as BlLink } from "./components/link/bl-link"; +export { default as BlNotification } from "./components/notification/bl-notification"; +export { default as BlNotificationCard } from "./components/notification/card/bl-notification-card"; export { default as BlPagination } from "./components/pagination/bl-pagination"; +export { default as BlPopover } from "./components/popover/bl-popover"; export { default as BlProgressIndicator } from "./components/progress-indicator/bl-progress-indicator"; export { default as BlRadioGroup } from "./components/radio-group/bl-radio-group"; export { default as BlRadio } from "./components/radio-group/radio/bl-radio"; export { default as BlSelect } from "./components/select/bl-select"; export { default as BlSelectOption } from "./components/select/option/bl-select-option"; -export { default as BlTab } from "./components/tab-group/tab/bl-tab"; +export { default as BlSpinner } from "./components/spinner/bl-spinner"; +export { default as BlSplitButton } from "./components/split-button/bl-split-button"; +export { default as BlSwitch } from "./components/switch/bl-switch"; export { default as BlTabGroup } from "./components/tab-group/bl-tab-group"; export { default as BlTabPanel } from "./components/tab-group/tab-panel/bl-tab-panel"; -export { default as BlTextarea } from "./components/textarea/bl-textarea"; -export { default as BlTooltip } from "./components/tooltip/bl-tooltip"; -export { default as BlPopover } from "./components/popover/bl-popover"; -export { default as BlDropdown } from "./components/dropdown/bl-dropdown"; -export { default as BlDropdownItem } from "./components/dropdown/item/bl-dropdown-item"; -export { default as BlDropdownGroup } from "./components/dropdown/group/bl-dropdown-group"; -export { default as BlSwitch } from "./components/switch/bl-switch"; -export { default as BlSpinner } from "./components/spinner/bl-spinner"; -export { default as BlNotification } from "./components/notification/bl-notification"; -export { default as BlNotificationCard } from "./components/notification/card/bl-notification-card"; +export { default as BlTab } from "./components/tab-group/tab/bl-tab"; export { default as BlTable } from "./components/table/bl-table"; -export { default as BlTableHeader } from "./components/table/table-header/bl-table-header"; export { default as BlTableBody } from "./components/table/table-body/bl-table-body"; -export { default as BlTableRow } from "./components/table/table-row/bl-table-row"; -export { default as BlTableHeaderCell } from "./components/table/table-header-cell/bl-table-header-cell"; export { default as BlTableCell } from "./components/table/table-cell/bl-table-cell"; -export { default as BlSplitButton } from "./components/split-button/bl-split-button"; -export { default as BlCalendar } from "./components/calendar/bl-calendar"; -export { default as BlDatePicker } from "./components/datepicker/bl-datepicker"; +export { default as BlTableHeaderCell } from "./components/table/table-header-cell/bl-table-header-cell"; +export { default as BlTableHeader } from "./components/table/table-header/bl-table-header"; +export { default as BlTableRow } from "./components/table/table-row/bl-table-row"; +export { default as BlTextarea } from "./components/textarea/bl-textarea"; +export { default as BlTooltip } from "./components/tooltip/bl-tooltip"; export { getIconPath, setIconPath } from "./utilities/asset-paths"; diff --git a/src/components/calendar/bl-calendar.css b/src/components/calendar/bl-calendar.css index 479839d1..a9882f22 100644 --- a/src/components/calendar/bl-calendar.css +++ b/src/components/calendar/bl-calendar.css @@ -127,6 +127,7 @@ .grid-item { width: 93.33px; + --bl-size-3xs: 15px; } diff --git a/src/components/datepicker/bl-datepicker.test.ts b/src/components/datepicker/bl-datepicker.test.ts index 36a52247..294daa20 100644 --- a/src/components/datepicker/bl-datepicker.test.ts +++ b/src/components/datepicker/bl-datepicker.test.ts @@ -1,16 +1,16 @@ import { aTimeout, expect, fixture, html } from "@open-wc/testing"; -import { BlButton, BlDatePicker } from "../../baklava"; -import { CALENDAR_TYPES } from "../calendar/bl-calendar.constant"; import sinon from "sinon"; +import { BlButton, BlDatepicker } from "../../baklava"; +import { CALENDAR_TYPES } from "../calendar/bl-calendar.constant"; import "./bl-datepicker"; describe("BlDatepicker", () => { - let element: BlDatePicker; + let element: BlDatepicker; let getElementByIdStub: sinon.SinonStub; let consoleWarnSpy: sinon.SinonSpy; beforeEach(async () => { - element = await fixture(html` + element = await fixture(html` `); // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -276,7 +276,7 @@ describe("BlDatepicker", () => { }); it("should warn when 'value' is not an array for multiple/range selection", async () => { - element = await fixture(html` + element = await fixture(html` `); element.value = new Date(); diff --git a/src/components/link/bl-link.css b/src/components/link/bl-link.css new file mode 100644 index 00000000..91a772b6 --- /dev/null +++ b/src/components/link/bl-link.css @@ -0,0 +1,87 @@ +:host { + display: inline-flex; + align-items: center; +} + +.link { + /* Custom properties */ + --color: var(--bl-link-color, var(--bl-color-primary)); + --hover-color: var(--bl-link-hover-color, var(--bl-color-primary-highlight)); + --active-color: var(--bl-link-active-color, var(--bl-color-primary-highlight)); + + /* Base styles */ + display: inline-flex; + align-items: center; + color: var(--color); + cursor: pointer; + outline: none; + position: relative; + font-family: var(--bl-font-family); +} + +/* States */ +.link:hover { + text-decoration: none; + color: var(--hover-color); +} + +.link:active { + color: var(--active-color); +} + +/* Focus styles */ +.link:focus-visible { + outline: none; +} + +.link:focus-visible::after { + content: ""; + position: absolute; + inset: -2px; + border: 2px solid var(--bl-color-primary); + border-radius: var(--bl-border-radius-s); +} + +/* Primary kind */ +.link.standalone.kind-primary { + color: var(--bl-color-primary); +} + +.link.standalone.kind-primary:hover, +.link.standalone.kind-primary:active { + color: var(--bl-color-primary-highlight); +} + +/* Neutral kind */ +.link.standalone.kind-neutral { + color: var(--bl-color-neutral); +} + +.link.standalone.kind-neutral:hover, +.link.standalone.kind-neutral:active { + color: var(--bl-color-neutral-highlight); +} + +/* Size variants */ +.link.standalone.size-large { + font-weight: var(--bl-font-weight-regular); + font-size: var(--bl-font-size-l); +} + +.link.standalone.size-medium { + font-weight: var(--bl-font-weight-regular); + font-size: var(--bl-font-size-m); +} + +.link.standalone.size-small { + font-weight: var(--bl-font-weight-regular); + font-size: var(--bl-font-size-s); +} + +/* Icon styles */ +::slotted([slot="icon"]) { + margin-inline-start: var(--bl-size-3xs); + margin-inline-end: var(--bl-size-3xs); + display: inline-block; + vertical-align: middle; +} diff --git a/src/components/link/bl-link.stories.ts b/src/components/link/bl-link.stories.ts new file mode 100644 index 00000000..175467a7 --- /dev/null +++ b/src/components/link/bl-link.stories.ts @@ -0,0 +1,332 @@ +import type { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { centeredLayout } from "../../utilities/chromatic-decorators"; + + +interface LinkArgs { + href?: string; + variant?: "inline" | "standalone"; + size?: "small" | "medium" | "large"; + kind?: "primary" | "neutral"; + target?: HTMLAnchorElement["target"]; + rel?: HTMLAnchorElement["rel"]; + hreflang?: HTMLAnchorElement["hreflang"]; + type?: HTMLAnchorElement["type"]; + referrerPolicy?: HTMLAnchorElement["referrerPolicy"]; + download?: HTMLAnchorElement["download"]; + ping?: HTMLAnchorElement["ping"]; + "aria-label"?: string; + content?: string; + customStyles?: string; + icon?: string; +} + +const FIGMA_LINK = "https://www.figma.com/design/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?node-id=23617-1414"; +const ADR_LINK = "https://github.com/Trendyol/baklava/blob/main/src/components/link/docs/ADR/link-component-implementation.md"; + +const meta: Meta = { + title: "Components/Link", + component: "bl-link", + parameters: { + chromatic: { + viewports: [1000] + }, + controls: { + exclude: ["content"], + }, + docs: { + description: { + component: + "
" + + "

" + + "The Link component is used for navigation between pages or to external URLs. " + + "It supports all native anchor tag attributes and provides additional styling variants. " + + "When not using the standalone variant, you can provide a custom icon using the icon slot." + + "

" + + + "
" + + "" + + "ADR" + + "" + + "" + + "Figma" + + "" + + "
" + + "
", + }, + }, + }, + decorators: [ + centeredLayout, + ], + argTypes: { + href: { + control: "text", + description: "URL that the hyperlink points to", + table: { + type: { summary: "string" }, + }, + }, + variant: { + control: { type: "select" }, + options: ["inline", "standalone"], + description: "Link variant", + table: { + type: { summary: "LinkVariant" }, + defaultValue: { summary: "inline" }, + }, + }, + size: { + control: { type: "select" }, + options: ["small", "medium", "large"], + description: "Link size (only applies to standalone variant)", + table: { + type: { summary: "LinkSize" }, + defaultValue: { summary: "medium" }, + }, + }, + kind: { + control: { type: "select" }, + options: ["primary", "neutral"], + description: "Link kind (only applies to standalone variant)", + table: { + type: { summary: "LinkKind" }, + defaultValue: { summary: "primary" }, + }, + }, + target: { + control: { type: "select" }, + options: ["_self", "_blank", "_parent", "_top"], + description: "Where to display the linked URL", + table: { + type: { summary: "HTMLAnchorElement['target']" }, + defaultValue: { summary: "_self" }, + }, + }, + rel: { + control: "text", + description: "Relationship between documents (e.g., noopener noreferrer)", + table: { + type: { summary: "HTMLAnchorElement['rel']" }, + }, + }, + hreflang: { + control: "text", + description: "Language of the linked document", + table: { + type: { summary: "HTMLAnchorElement['hreflang']" }, + }, + }, + type: { + control: "text", + description: "MIME type of the linked document", + table: { + type: { summary: "HTMLAnchorElement['type']" }, + }, + }, + referrerPolicy: { + control: { type: "select" }, + options: [ + "no-referrer", + "no-referrer-when-downgrade", + "origin", + "origin-when-cross-origin", + "same-origin", + "strict-origin", + "strict-origin-when-cross-origin", + "unsafe-url", + ], + description: "Referrer policy for the link", + table: { + type: { summary: "HTMLAnchorElement['referrerPolicy']" }, + }, + }, + download: { + control: "text", + description: "Whether to download the resource instead of navigating to it", + table: { + type: { summary: "HTMLAnchorElement['download']" }, + }, + }, + ping: { + control: "text", + description: "URLs to be notified when following the link", + table: { + type: { summary: "HTMLAnchorElement['ping']" }, + }, + }, + "aria-label": { + control: "text", + description: "Aria label for accessibility", + table: { + type: { summary: "string" }, + }, + }, + icon: { + control: "text", + description: "Icon name for custom icon (only applies to non-standalone variants)", + table: { + type: { summary: "string" }, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const LinkTemplate = (args: LinkArgs) => html` + + ${args.content || "Link Text"} + ${args.icon ? html`` : ""} + +`; + +export const Default: Story = { + args: { + href: "/", + content: "Link", + }, + render: LinkTemplate, +}; + +export const InlineLink: Story = { + args: { + href: "/", + variant: "inline", + content: "Link", + }, + render: (args) => html` +
+ + Inline variant should be used within a text container. Example: +
+

Text with a link inside.

+
+
+ +

+ This is a paragraph with a ${LinkTemplate(args)} in the text. +

+ +

+ This is a paragraph with a ${LinkTemplate(args)} in the text. +

+ +

+ This is a paragraph with a ${LinkTemplate(args)} in the text. +

+
+ `, +}; + +export const StandaloneLink: Story = { + args: { + href: "/", + variant: "standalone", + content: "Link", + }, + render: LinkTemplate, +}; + +export const CustomIconLink: Story = { + args: { + href: "/", + content: "Link with Custom Icon", + icon: "external_link", + }, + render: LinkTemplate, +}; + +export const SizeVariants: Story = { + args: { + variant: "standalone", + }, + render: () => html` +
+ ${LinkTemplate({ href: "/", variant: "standalone", size: "small", content: "Small" })} + ${LinkTemplate({ href: "/", variant: "standalone", size: "medium", content: "Medium" })} + ${LinkTemplate({ href: "/", variant: "standalone", size: "large", content: "Large" })} +
+ `, +}; + +export const KindVariants: Story = { + args: { + variant: "standalone", + }, + render: () => html` +
+ ${LinkTemplate({ href: "/", variant: "standalone", kind: "primary", content: "Primary" })} + ${LinkTemplate({ href: "/", variant: "standalone", kind: "neutral", content: "Neutral" })} +
+ `, +}; + +export const NativeAnchorAttributes: Story = { + args: { + href: "https://example.com", + target: "_blank", + rel: "noopener noreferrer", + hreflang: "en", + type: "text/html", + referrerPolicy: "no-referrer", + download: "file.pdf", + ping: "https://analytics.example.com", + content: "External Link with Native Attributes", + }, + render: LinkTemplate, +}; + +export const AccessibleLink: Story = { + args: { + href: "/", + "aria-label": "View your profile settings", + content: "Profile", + + }, + render: LinkTemplate, +}; + +export const CustomInlineColors: Story = { + render: () => html` +
+ ${LinkTemplate({ + href: "/", + content: "Success Link", + customStyles: "--bl-link-color: var(--bl-color-success); --bl-link-hover-color: var(--bl-color-success-highlight); --bl-link-active-color: var(--bl-color-success-highlight);" + })} + ${LinkTemplate({ + href: "/", + content: "Warning Link", + customStyles: "--bl-link-color: var(--bl-color-warning); --bl-link-hover-color: var(--bl-color-warning-highlight); --bl-link-active-color: var(--bl-color-warning-highlight);" + })} + ${LinkTemplate({ + href: "/", + content: "Danger Link", + customStyles: "--bl-link-color: var(--bl-color-danger); --bl-link-hover-color: var(--bl-color-danger-highlight); --bl-link-active-color: var(--bl-color-danger-highlight);" + })} +
+ `, +}; + diff --git a/src/components/link/bl-link.test.ts b/src/components/link/bl-link.test.ts new file mode 100644 index 00000000..813025e9 --- /dev/null +++ b/src/components/link/bl-link.test.ts @@ -0,0 +1,184 @@ +import { expect, fixture, html } from "@open-wc/testing"; +import * as sinon from "sinon"; +import BlLink from "./bl-link"; + +describe("bl-link", () => { + it("verifies static properties", () => { + expect(BlLink.styles).to.exist; + }); + + it("renders with default properties", async () => { + const el = await fixture(html`Home`); + + expect(el.variant).to.equal("inline"); + expect(el.size).to.equal("medium"); + expect(el.kind).to.equal("primary"); + expect(el.href).to.equal("javascript:void(0)"); + expect(el.target).to.equal("_self"); + expect(el.rel).to.be.undefined; + expect(el.hreflang).to.be.undefined; + expect(el.type).to.be.undefined; + expect(el.referrerPolicy).to.be.undefined; + expect(el.download).to.be.undefined; + expect(el.ping).to.be.undefined; + expect(el.ariaLabel).to.equal(""); + }); + + it("renders link attributes correctly", async () => { + const el = await fixture(html` + External Link + `); + const link = el.shadowRoot!.querySelector("a"); + + expect(link).to.exist; + expect(link?.getAttribute("href")).to.equal("https://example.com"); + expect(link?.getAttribute("target")).to.equal("_blank"); + expect(link?.getAttribute("rel")).to.equal("noopener"); + expect(link?.getAttribute("hreflang")).to.equal("en"); + expect(link?.getAttribute("type")).to.equal("text/html"); + expect(link?.getAttribute("referrerpolicy")).to.equal("no-referrer"); + expect(link?.getAttribute("download")).to.equal("file.pdf"); + expect(link?.getAttribute("ping")).to.equal("https://analytics.example.com"); + expect(link?.getAttribute("role")).to.equal("link"); + expect(link?.getAttribute("tabindex")).to.equal("0"); + }); + + it("renders icons correctly", async () => { + const el = await fixture(html`Home`); + + expect(el.shadowRoot!.querySelector("bl-icon")?.getAttribute("name")).to.equal("arrow_right"); + + el.variant = "inline"; + await el.updateComplete; + expect(el.shadowRoot!.querySelector("bl-icon")).to.be.null; + + el.variant = "standalone"; + await el.updateComplete; + expect(el.shadowRoot!.querySelector("bl-icon")?.getAttribute("name")).to.equal("arrow_right"); + }); + + it("handles inline variant warning", async () => { + const consoleWarnSpy = sinon.spy(console, "warn"); + + // Test inline variant without text sibling + await fixture(html`
Link
`); + expect(consoleWarnSpy.calledOnce).to.be.true; + expect(consoleWarnSpy.calledWith( + "bl-link: Inline variant should be used within a text container. Example:

Text with a link inside.

" + )).to.be.true; + + // Test inline variant with text sibling + consoleWarnSpy.resetHistory(); + await fixture(html`
Text Link
`); + expect(consoleWarnSpy.notCalled).to.be.true; + + // Test with no parent element + consoleWarnSpy.resetHistory(); + const el = document.createElement("bl-link") as BlLink; + + el.variant = "inline"; + document.body.appendChild(el); + expect(consoleWarnSpy.calledOnce).to.be.true; + document.body.removeChild(el); + + // Test with parent element but no childNodes + consoleWarnSpy.resetHistory(); + const parentEl = document.createElement("div"); + + Object.defineProperty(parentEl, "childNodes", { value: null }); + const linkEl = document.createElement("bl-link") as BlLink; + + linkEl.variant = "inline"; + parentEl.appendChild(linkEl); + document.body.appendChild(parentEl); + expect(consoleWarnSpy.calledOnce).to.be.true; + document.body.removeChild(parentEl); + + // Test with non-inline variant + consoleWarnSpy.resetHistory(); + await fixture(html`Link`); + expect(consoleWarnSpy.notCalled).to.be.true; + + consoleWarnSpy.restore(); + }); + + it("handles all property combinations", async () => { + const el = await fixture(html` + Home + `); + + const link = el.shadowRoot!.querySelector("a"); + + expect(link?.classList.contains("link")).to.be.true; + expect(link?.classList.contains("standalone")).to.be.true; + expect(link?.classList.contains("size-large")).to.be.true; + expect(link?.classList.contains("kind-neutral")).to.be.true; + expect(link?.getAttribute("aria-label")).to.equal("Home page"); + expect(link?.getAttribute("href")).to.equal("javascript:void(0)"); + expect(link?.getAttribute("target")).to.equal("_blank"); + expect(link?.getAttribute("rel")).to.equal("noopener"); + expect(link?.getAttribute("hreflang")).to.equal("en"); + expect(link?.getAttribute("type")).to.equal("text/html"); + expect(link?.getAttribute("referrerpolicy")).to.equal("no-referrer"); + + // Test all size variants + const sizes = ["small", "medium", "large"] as const; + + for (const size of sizes) { + el.size = size; + await el.updateComplete; + expect(link?.classList.contains(`size-${size}`)).to.be.true; + } + + // Test all kind variants + const kinds = ["primary", "neutral"] as const; + + for (const kind of kinds) { + el.kind = kind; + await el.updateComplete; + expect(link?.classList.contains(`kind-${kind}`)).to.be.true; + } + + // Test all target variants + const targets: ["_self", "_blank", "_parent", "_top"] = ["_self", "_blank", "_parent", "_top"]; + + for (const target of targets) { + el.target = target; + await el.updateComplete; + expect(link?.getAttribute("target")).to.equal(target); + } + }); + + it("renders custom icon slot", async () => { + const el = await fixture(html` + + Link Text + + + `); + + const slot = el.shadowRoot!.querySelector('slot[name="icon"]'); + + expect(slot).to.exist; + }); +}); diff --git a/src/components/link/bl-link.ts b/src/components/link/bl-link.ts new file mode 100644 index 00000000..3cd132d3 --- /dev/null +++ b/src/components/link/bl-link.ts @@ -0,0 +1,168 @@ +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import style from "./bl-link.css"; + +export type LinkVariant = "inline" | "standalone"; +export type LinkSize = "large" | "medium" | "small"; +export type LinkKind = "primary" | "neutral"; + +/** + * @tag bl-link + * @summary Baklava Link component for navigation + * + * @slot icon - Custom icon slot for non-standalone variants + * + * @cssproperty [--bl-link-color=--bl-color-primary] Sets the color of link + * @cssproperty [--bl-link-hover-color=--bl-color-primary-hover] Sets the hover color of link + * @cssproperty [--bl-link-active-color=--bl-color-primary-active] Sets the active color of link + */ + +@customElement("bl-link") +export default class BlLink extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * URL that the hyperlink points to + */ + @property({ type: String, reflect: true }) + href: HTMLAnchorElement["href"] = ""; + + /** + * Link variant - inline or standalone + */ + @property({ type: String, reflect: true }) + variant: LinkVariant = "inline"; + + /** + * Link size - only applies to standalone variant + */ + @property({ type: String, reflect: true }) + size: LinkSize = "medium"; + + /** + * Link kind - only applies to standalone variant + */ + @property({ type: String, reflect: true }) + kind: LinkKind = "primary"; + + /** + * Aria label for the link + */ + @property({ type: String, attribute: "aria-label" }) + ariaLabel = ""; + + /** + * Where to display the linked URL + */ + @property({ type: String, reflect: true }) + target: HTMLAnchorElement["target"] = "_self"; + + /** + * Relationship between the current document and the linked document. + * Multiple rel values can be specified by separating them with spaces. + * Example: "noopener noreferrer" + */ + @property({ type: String, reflect: true }) + rel?: HTMLAnchorElement["rel"]; + + /** + * Language of the linked document + */ + @property({ type: String, reflect: true }) + hreflang?: HTMLAnchorElement["hreflang"]; + + /** + * MIME type of the linked document + */ + @property({ type: String, reflect: true }) + type?: HTMLAnchorElement["type"]; + + /** + * Referrer policy for the link + */ + @property({ type: String, reflect: true, attribute: "referrerpolicy" }) + referrerPolicy?: HTMLAnchorElement["referrerPolicy"]; + + /** + * Whether to download the resource instead of navigating to it + */ + @property({ type: String, reflect: true }) + download?: HTMLAnchorElement["download"]; + + /** + * Ping URLs to be notified when following the link + */ + @property({ type: String, reflect: true }) + ping?: HTMLAnchorElement["ping"]; + + private get isStandalone(): boolean { + return this.variant === "standalone"; + } + + private renderIcon(): TemplateResult | null { + if (this.isStandalone) { + return html``; + } + return html``; + } + + connectedCallback() { + super.connectedCallback(); + + if (this.variant === "inline") { + const parentElement = this.parentElement; + const hasTextSibling = Array.from(parentElement?.childNodes || []).some( + node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim() + ); + + if (!parentElement || !hasTextSibling) { + console.warn( + "bl-link: Inline variant should be used within a text container. Example:

Text with a link inside.

" + ); + } + } + } + + render(): TemplateResult { + const classes = { + link: true, + standalone: this.isStandalone, + [`size-${this.size}`]: this.isStandalone, + [`kind-${this.kind}`]: this.isStandalone, + }; + + const content = html` + + ${this.renderIcon()} + `; + + return html` + + ${content} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "bl-link": BlLink; + } +} diff --git a/src/components/link/docs/ADR/link-component-implementation.md b/src/components/link/docs/ADR/link-component-implementation.md new file mode 100644 index 00000000..ada934bd --- /dev/null +++ b/src/components/link/docs/ADR/link-component-implementation.md @@ -0,0 +1,243 @@ +# Link Component Implementation + +## Status + +Implemented + +## Context + +We need a consistent way to handle navigation and links throughout the application. Links are fundamental UI elements that can be used in different contexts: + +- Within text content (inline) +- As standalone elements +- For internal navigation + +## Decision + +We will implement a Link component with the following key characteristics: + +1. Two main variants: + - Inline Links: For use within text content (with validation) + - Standalone Links: For use as independent elements with arrow icon + +2. Design Constraints: + - Default color will be primary color from the color palette + - Standalone links will include an arrow icon on the right + - Non-standalone links can have custom icons via slot + - Three sizes for standalone links: Large, Medium, and Small + - Links will support hover and focus states + - Links will support custom colors through CSS properties + - Links will have proper RTL support using logical properties + - Inline links must be used within text content + +3. Usage Guidelines: + - Links should only be used for navigation purposes + - For actions that change data or trigger functionality, buttons should be used instead + - Inline variant must be used within text content + - Links can target: + - Internal routes within the application + - Elements on the same page (anchor links) + +4. Technical Implementation: + - TypeScript and Lit are used for type safety and web component implementation + - Props include: + ```typescript + interface LinkProps { + href: HTMLAnchorElement["href"]; // URL that the hyperlink points to + variant?: "inline" | "standalone"; // Link variant (default: "inline") + size?: "large" | "medium" | "small"; // Link size for standalone variant (default: "medium") + kind?: "primary" | "neutral"; // Link kind for standalone variant (default: "primary") + target?: HTMLAnchorElement["target"]; // Where to display the linked URL (default: "_self") + rel?: HTMLAnchorElement["rel"]; // Relationship between documents + hreflang?: HTMLAnchorElement["hreflang"]; // Language of the linked document + type?: HTMLAnchorElement["type"]; // MIME type of the linked document + referrerPolicy?: HTMLAnchorElement["referrerPolicy"]; // Referrer policy for the link + download?: HTMLAnchorElement["download"]; // Whether to download the resource + ping?: HTMLAnchorElement["ping"]; // URLs to be notified when following the link + "aria-label"?: string; // Aria label for accessibility + } + ``` + - Slots: + ```typescript + /** + * @slot icon - Custom icon slot for non-standalone variants + */ + ``` + - CSS Custom Properties: + ```css + :host { + --bl-link-color: var(--bl-color-primary); /* Default link color */ + --bl-link-hover-color: var(--bl-color-primary-hover); /* Hover state color */ + --bl-link-active-color: var(--bl-color-primary-active); /* Active state color */ + } + ``` + - Inline Variant Validation: + ```typescript + connectedCallback() { + super.connectedCallback(); + + if (this.variant === "inline") { + const parentElement = this.parentElement; + const hasTextSibling = Array.from(parentElement?.childNodes || []).some( + node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim() + ); + + if (!parentElement || !hasTextSibling) { + console.warn( + "bl-link: Inline variant should be used within a text container. Example:

Text with a link inside.

" + ); + } + } + } + ``` + +5. Example Usage: + + 1. Basic Link: + ```html + About Page + ``` + + 2. Inline Link in Text (✅ Correct Usage): + ```html +

+ This is a paragraph with an + About Page + link in the text. +

+ ``` + + 3. Inline Link Without Text (⚠️ Warning): + ```html + +
+ About Page +
+ ``` + + 4. Standalone Link: + ```html + + About Page + + ``` + + 5. Link with Custom Icon: + ```html + + Settings + + + ``` + + 6. Link with Native Anchor Attributes: + ```html + + External Link + + + ``` + + 7. Custom Colored Link: + ```html + + Success Link + + + ``` + +## Features + +1. Variants: + - Inline: For use within text content (with validation) + - Standalone: For use as independent elements with arrow icon + +2. Icons: + - Standalone: Fixed arrow icon on the right + - Non-standalone: Customizable icon via slot + +3. Sizes (for standalone variant): + - Small + - Medium (default) + - Large + +4. Kinds (for standalone variant): + - Primary (default) + - Neutral + +5. States: + - Default + - Hover + - Active + - Focus + +6. Native Anchor Attributes: + - href: URL destination + - target: Link target (_self, _blank, etc.) + - rel: Document relationships + - hreflang: Language of linked document + - type: MIME type + - referrerPolicy: Referrer policy + - download: Download behavior + - ping: Ping notifications + +7. Accessibility: + - Proper ARIA attributes + - Keyboard navigation support + - Focus management + +8. RTL Support: + - Uses CSS logical properties + - Icons properly positioned in RTL layouts + +9. Validation: + - Inline variant usage validation + - Warning for incorrect usage + - Runtime checks for proper context + +## Consequences + +### Positive +- Consistent navigation pattern across the application +- Clear separation between navigation (links) and actions (buttons) +- Type-safe implementation with TypeScript and native HTML types +- Full support for all native anchor tag attributes +- Flexible icon customization for non-standalone variants +- Maintainable and scalable component structure +- Proper accessibility support +- RTL language support +- Customizable through CSS properties +- Validation for proper inline variant usage + +### Negative +- Additional complexity in maintaining two variants +- Need to educate team members on proper usage (links vs buttons) +- Additional development time for implementing all states and variants +- Runtime validation overhead for inline variant + +## Resources + +- [Storybook Documentation](https://baklava.design/components/link) +- [Figma Design](https://www.figma.com/file/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?node-id=23617-1414) +- [MDN Anchor Element Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) +