Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/footer-copyright-skip-nav.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"brand-shell": minor
---

Add `copyrightText` customization and skip navigation link.

- `BrandDetails.copyrightText` — optional field to override the default `© {year} {name}` footer line
- Skip nav link (`<a href="#main-content">Skip to main content</a>`) added as the first child of every `<header>`, visually hidden until keyboard-focused, inherits the active theme's CSS variables
3 changes: 2 additions & 1 deletion schemas/brand-shell.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@
"items": { "$ref": "#/$defs/CustomSocialLink" }
},
"logoSrc": { "type": "string", "minLength": 1 },
"logoAlt": { "type": "string", "minLength": 1 }
"logoAlt": { "type": "string", "minLength": 1 },
"copyrightText": { "type": "string", "minLength": 1 }
}
},
"BrandTheme": {
Expand Down
4 changes: 3 additions & 1 deletion src/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ export function Footer({ details, theme, className, renderLink }: FooterProps) {
</div>
)}
</div>
<p className="brand-shell-footer__copy">&copy; {new Date().getFullYear()} {normalizedDetails.name}</p>
<p className="brand-shell-footer__copy">
{normalizedDetails.copyrightText ?? `\u00a9 ${new Date().getFullYear()} ${normalizedDetails.name}`}
</p>
</div>
</footer>
);
Expand Down
1 change: 1 addition & 0 deletions src/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function Header({ details, theme, className, renderLink }: HeaderProps) {

return (
<header className={combinedClassName} data-brand-cta-layout={ctaLayout} style={style} role="banner">
<a href="#main-content" className="brand-shell-skip-nav">Skip to main content</a>
<div className="brand-shell-header__inner">
{brandIdentity}
<div className="brand-shell-header__actions">
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export interface BrandDetails {
logoSrc?: string;
/** Alt text for the logo. Defaults to `name` if omitted. */
logoAlt?: string;
/** Custom copyright line shown in the footer. Defaults to `© {year} {name}` when omitted. */
copyrightText?: string;
}

/**
Expand Down
20 changes: 20 additions & 0 deletions src/core/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,26 @@ describe("validateBrandDetails", () => {
expect(result.normalized?.logoSrc).toBe("https://example.com/logo.png");
expect(result.normalized?.logoAlt).toBeUndefined();
});

it("accepts copyrightText and passes it through normalized", () => {
const result = validateBrandDetails({
name: "Acme Corp",
copyrightText: "© 2026 Acme Corp. All rights reserved.",
});

expect(result.valid).toBe(true);
expect(result.normalized?.copyrightText).toBe("© 2026 Acme Corp. All rights reserved.");
});

it("rejects empty copyrightText", () => {
const result = validateBrandDetails({
name: "Acme Corp",
copyrightText: " ",
});

expect(result.valid).toBe(false);
expect(result.errors).toContain("details.copyrightText must be a non-empty string when provided.");
});
});

describe("validateBrandTheme", () => {
Expand Down
1 change: 1 addition & 0 deletions src/core/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function validateBrandDetails(details: unknown): BrandValidationResult<No
validateOptionalString(details.logoSrc, "details.logoSrc", errors);
validateSafeHref(details.logoSrc, "details.logoSrc", errors);
validateOptionalString(details.logoAlt, "details.logoAlt", errors);
validateOptionalString(details.copyrightText, "details.copyrightText", errors);

if (details.navLinks != null) {
if (!Array.isArray(details.navLinks)) {
Expand Down
9 changes: 9 additions & 0 deletions src/stories/Footer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,12 @@ export const WithLogoAndCustomHeight: Story = {
},
},
};

export const CustomCopyright: Story = {
args: {
details: {
...sharedDetails,
copyrightText: "© 2026 Acme Corp. All rights reserved.",
},
},
};
10 changes: 10 additions & 0 deletions src/stories/Header.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,13 @@ export const WithLogoAndCustomHeight: Story = {
},
},
};

export const SkipNavFocused: Story = {
parameters: {
docs: {
description: {
story: "Tab once after load to reveal the skip navigation link. Pressing Enter jumps focus to `#main-content`.",
},
},
},
};
8 changes: 7 additions & 1 deletion src/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,12 @@ function createHeader(
applyThemeVariables(header, theme);
header.dataset.brandCtaLayout = resolveCtaLayout(theme);

const skipNav = document.createElement("a");
skipNav.href = "#main-content";
skipNav.className = "brand-shell-skip-nav";
skipNav.textContent = "Skip to main content";
header.append(skipNav);

const inner = document.createElement("div");
inner.className = "brand-shell-header__inner";

Expand Down Expand Up @@ -394,7 +400,7 @@ function createFooter(
top.append(createSocialLinks(socialLinks, "brand-shell-footer__social", "brand-shell-footer__social-link"));
}

const copy = createParagraph("brand-shell-footer__copy", `© ${new Date().getFullYear()} ${details.name}`);
const copy = createParagraph("brand-shell-footer__copy", details.copyrightText ?? `© ${new Date().getFullYear()} ${details.name}`);

inner.append(top, copy);
footer.append(inner);
Expand Down
25 changes: 25 additions & 0 deletions styles/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,31 @@
--brand-button-secondary: rgba(255, 255, 255, 0.12);
}

/* Skip navigation link — visually hidden until focused */
.brand-shell-skip-nav {
position: absolute;
top: 0;
left: 0;
z-index: 9999;
padding: 0.6rem 1.2rem;
background: var(--_bg, var(--brand-bg));
color: var(--_text, var(--brand-text));
font-family: var(--_font, var(--brand-font));
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
border-radius: var(--brand-radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
transform: translateY(-150%);
transition: transform 0.15s ease;
}

.brand-shell-skip-nav:focus-visible {
transform: translateY(0);
outline: 2px solid var(--_primary, var(--brand-primary));
outline-offset: 2px;
}

.brand-shell-header,
.brand-shell-header *,
.brand-shell-header *::before,
Expand Down