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
7 changes: 7 additions & 0 deletions .changeset/site-kit-family-footer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@devslab/site-kit": minor
---

`SiteFooter` renders the family footer: an optional language row (`locale`, `localeRegistry`, `onLocaleSelect`), the brand mark it already accepted but dropped, middot-separated `family` links after the brand name, and a copyright isolated in `<bdi>` with an optional `copyrightHref`. `styles.css` gains the `.site-footer__langs` / `__row` / `__brand` rules. Additive — a caller passing only brand/links/copyright/messages keeps its single row, plus its mark. VisionLinq, BookLinq and TraceLinq each carried a hand-written copy of this footer, and each left a note saying extending `SiteFooter` was a dds release they were waiting on; this is that release.

`SiteFooter`가 가족 푸터를 그립니다 — 선택적 언어 행(`locale`·`localeRegistry`·`onLocaleSelect`), 받고도 버리던 브랜드 마크, 브랜드 이름 뒤 가운뎃점으로 이어지는 `family` 링크, `<bdi>`로 감싼 저작권(선택적 `copyrightHref`). `styles.css`에 `.site-footer__langs`·`__row`·`__brand` 규칙 추가. 덧붙이기만 하므로 brand/links/copyright/messages만 넘기던 호출부는 한 행 그대로에 마크만 더해집니다. VisionLinq·BookLinq·TraceLinq가 각자 이 푸터를 손으로 짜 놓고 저마다 "SiteFooter 확장은 기다려야 할 dds 릴리스"라고 적어 뒀는데, 그 릴리스입니다.
146 changes: 146 additions & 0 deletions packages/site-kit/src/solid/__tests__/footer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { render } from "solid-js/web";
import { afterEach, expect, it } from "vitest";

import * as kit from "../index";
import { FAMILY_LOCALES, defineLocaleRegistry } from "../../core/locales.mjs";

let dispose: (() => void) | undefined;
afterEach(() => {
dispose?.();
dispose = undefined;
document.body.replaceChildren();
});

function mount(node: () => any) {
const host = document.body.appendChild(document.createElement("div"));
dispose = render(node, host);
return host;
}

const STYLES = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), "../../../styles.css"),
"utf8",
);

const messages = {
navigationLabel: "Navigation",
localeLabel: "Language",
themeLabel: "Theme",
themeSystem: "System",
themeLight: "Light",
themeDark: "Dark",
menuOpen: "Open menu",
menuClose: "Close menu",
footerLabel: "Footer",
skipToContent: "Skip to content",
updatedLabel: "Updated",
notFoundTitle: "Not found",
notFoundDescription: "No such page",
backHome: "Home",
errorTitle: "Error",
errorDescription: "Something failed",
retry: "Retry",
};

const base = {
brand: { name: "AskLinq", href: "/" },
links: [{ href: "/privacy", label: "Privacy" }],
copyright: "© 2026 DevsLab",
messages,
};

it("renders one row when no language row is asked for", () => {
const { SiteFooter } = kit;
const host = mount(() => <SiteFooter {...base} />);
// A product whose header already offers every language does not want a
// second copy of the list, so the row appears only when `locale` is passed.
expect(host.querySelector(".site-footer__langs")).toBeNull();
expect(host.querySelector(".site-footer__brand strong")?.textContent).toBe("AskLinq");
expect([...host.querySelectorAll(".site-footer__links a")].map((a) => a.textContent)).toEqual(["Privacy"]);
});

it("renders the brand mark the caller passes", () => {
const { SiteFooter } = kit;
// The kit used to accept `brand.logo` and drop it, which is why AskLinq's
// footer shipped without the family mark while every other product drew one.
const host = mount(() => <SiteFooter {...base} brand={{ ...base.brand, logo: <svg data-testid="mark" /> }} />);
expect(host.querySelector(".site-footer__brand [data-testid='mark']")).not.toBeNull();
});

it("lists every family language, marking the current one, and reports the pick", () => {
const { SiteFooter } = kit;
const picked: string[] = [];
const host = mount(() => <SiteFooter
{...base}
locale={{ locale: "ko", hrefForLocale: (code: string) => `/${code}` }}
onLocaleSelect={(code) => picked.push(code)}
/>);
const links = [...host.querySelectorAll<HTMLAnchorElement>(".site-footer__langs a")];
expect(links).toHaveLength(FAMILY_LOCALES.LOCALES.length);

const korean = links.find((a) => a.getAttribute("hreflang") === "ko")!;
expect(korean.getAttribute("aria-current")).toBe("page");
expect(korean.getAttribute("href")).toBe("/ko");

const arabic = links.find((a) => a.getAttribute("hreflang") === "ar");
// Each link is written in its own language, so it carries its own direction.
if (arabic) expect(arabic.getAttribute("dir")).toBe("rtl");
expect(links.filter((a) => a.getAttribute("aria-current") === "page")).toHaveLength(1);

korean.click();
expect(picked).toEqual(["ko"]);
});

it("honours a locale subset registry", () => {
const { SiteFooter } = kit;
const registry = defineLocaleRegistry({ only: ["ko", "en", "ja"] });
const host = mount(() => <SiteFooter
{...base}
locale={{ locale: "en", hrefForLocale: (code: string) => `/${code}` }}
localeRegistry={registry}
/>);
expect([...host.querySelectorAll(".site-footer__langs a")].map((a) => a.getAttribute("hreflang"))).toEqual(["ko", "en", "ja"]);
});

it("puts the family links after the brand, separated for sighted readers only", () => {
const { SiteFooter } = kit;
const host = mount(() => <SiteFooter
{...base}
family={[{ href: "https://devslab.kr/#products", label: "Linq family" }, { href: "https://devslab.kr/", label: "DevsLab" }]}
/>);
const brand = host.querySelector(".site-footer__brand")!;
expect([...brand.querySelectorAll("a")].map((a) => a.textContent)).toEqual(["Linq family", "DevsLab"]);
// The middots are decoration between names; a screen reader should read the
// names, not "dot".
expect([...brand.querySelectorAll("span")].every((s) => s.getAttribute("aria-hidden") === "true")).toBe(true);
});

it("isolates the copyright so an RTL page does not reorder it, with or without a link", () => {
const { SiteFooter } = kit;
const plain = mount(() => <SiteFooter {...base} />);
expect(plain.querySelector(".site-footer__links bdi")?.textContent).toBe("© 2026 DevsLab");
expect(plain.querySelector(".site-footer__links li:last-child a")).toBeNull();

dispose?.();
dispose = undefined;
document.body.replaceChildren();

const linked = mount(() => <SiteFooter {...base} copyrightHref="https://devslab.kr/" />);
const anchor = linked.querySelector<HTMLAnchorElement>(".site-footer__links li:last-child a")!;
expect(anchor.getAttribute("href")).toBe("https://devslab.kr/");
expect(anchor.querySelector("bdi")?.textContent).toBe("© 2026 DevsLab");
});

it("ships the rules the three rows need, so no consumer has to keep its own copy", () => {
for (const rule of [
".site-footer__langs",
".site-footer__langs a[aria-current=\"page\"]",
".site-footer__row",
".site-footer__brand",
]) {
expect(STYLES).toContain(rule);
}
});
73 changes: 68 additions & 5 deletions packages/site-kit/src/solid/chrome.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Button, Icon, IconButton } from "@devslab/dds-solid";
import { For, createSignal, onMount, type JSX } from "solid-js";
import { For, Show, createSignal, onMount, type JSX } from "solid-js";

import { LocaleMenu, type LocaleMenuProps, type LocaleMenuVariant } from "./locale-menu";
import type { LocaleRegistry } from "../core/locales.mjs";
import { FAMILY_LOCALES, type LocaleRegistry, type SiteLocale } from "../core/locales.mjs";
import type { LocaleState, SiteBrand, SiteLink, SiteMessages, ThemePreference } from "./types";

export type { LocaleMenuProps };
Expand Down Expand Up @@ -109,16 +109,79 @@ export interface SiteFooterProps {
brand: SiteBrand;
links: SiteLink[];
copyright: string;
/** Wraps the copyright in a link — the family site, usually. */
copyrightHref?: string;
messages: SiteMessages;
/**
* The language row. Omit it and no row is rendered: a product whose header
* already offers every language may not want a second copy of the list.
*/
locale?: LocaleState;
/** The languages the row lists. Defaults to the family's fourteen. */
localeRegistry?: LocaleRegistry<string>;
/**
* Called with the code the reader picked, before the browser follows the
* link — for a product that remembers the choice in a cookie.
*/
onLocaleSelect?: (locale: string) => void;
/** Links after the brand name, middot-separated: the family line, the operator. */
family?: SiteLink[];
}

/**
* The family footer: a language row, then the brand beside its family links,
* then the page's links and the copyright.
*
* Every product wrote this by hand. VisionLinq, BookLinq and TraceLinq each
* carried their own copy with a comment saying the kit's footer "takes only a
* flat link list; extending it is a dds release this page would then wait on" —
* three copies and three notes naming the same missing release. Their CSS had
* already converged byte-for-byte. This is that release.
*
* Additive: a caller that passes only brand/links/copyright/messages gets the
* same single row it got before, plus its brand mark if it set one.
*/
export function SiteFooter(props: SiteFooterProps) {
const registry = () => props.localeRegistry ?? (FAMILY_LOCALES as LocaleRegistry<string>);
return (
<footer class="site-footer" aria-label={props.messages.footerLabel}>
<div class="site-footer__inner">
<strong>{props.brand.name}</strong>
<ul class="site-footer__links"><For each={props.links}>{(item) => <li><a href={item.href}>{item.label}</a></li>}</For></ul>
<small>{props.copyright}</small>
<Show when={props.locale}>{(locale) => (
<nav class="site-footer__langs" aria-label={props.messages.localeLabel}>
<For each={registry().LOCALES}>{(entry) => (
<a
href={locale().hrefForLocale(entry.code as SiteLocale)}
hreflang={entry.code}
lang={entry.code}
dir={entry.dir}
aria-current={entry.code === locale().locale ? "page" : undefined}
onClick={() => props.onLocaleSelect?.(entry.code)}
>{entry.nativeName}</a>
)}</For>
</nav>
)}</Show>
<div class="site-footer__row">
<p class="site-footer__brand">
{props.brand.logo}
<strong>{props.brand.name}</strong>
<For each={props.family ?? []}>{(item) => <>
<span aria-hidden="true">·</span>
<a href={item.href}>{item.label}</a>
</>}</For>
</p>
<ul class="site-footer__links">
<For each={props.links}>{(item) => <li><a href={item.href}>{item.label}</a></li>}</For>
{/*
A copyright like "© 2026 DevsLab" mixes neutral, digit and Latin
runs, which the bidi algorithm reorders on an RTL page into
"DevsLab 2026 ©". <bdi> isolates it so it reads as written in
every direction.
*/}
<li><Show when={props.copyrightHref} fallback={<bdi>{props.copyright}</bdi>}>
{(href) => <a href={href()}><bdi>{props.copyright}</bdi></a>}
</Show></li>
</ul>
</div>
</div>
</footer>
);
Expand Down
9 changes: 9 additions & 0 deletions packages/site-kit/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
.site-locale { inline-size: auto; min-inline-size: 9rem; }
.site-footer { border-block-start: 1px solid var(--dds-color-border-default); margin-block-start: var(--dds-space-64); padding-block: var(--dds-space-32); }
.site-footer__inner { inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; display: grid; gap: var(--dds-space-16); }
.site-footer__langs { display: flex; flex-wrap: wrap; gap: var(--dds-space-8) var(--dds-space-16); font-size: var(--dds-typo-caption-font-size); }
.site-footer__langs a { color: var(--dds-color-text-muted); text-decoration: none; }
.site-footer__langs a:hover { color: var(--dds-color-text-primary); }
.site-footer__langs a[aria-current="page"] { color: var(--dds-color-text-primary); font-weight: 600; }
.site-footer__row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--dds-space-16) var(--dds-space-32); }
.site-footer__brand { display: inline-flex; align-items: center; gap: var(--dds-space-8); margin: 0; color: var(--dds-color-text-secondary); }
.site-footer__brand img, .site-footer__brand svg { block-size: 16px; inline-size: 16px; }
.site-footer__brand a { color: inherit; text-decoration: none; }
.site-footer__brand a:hover { color: var(--dds-color-text-primary); text-decoration: underline; }
.site-legal { max-inline-size: 760px; margin-inline: auto; padding-block: var(--dds-space-40); }
.site-status { border: 1px solid var(--dds-color-border-default); border-inline-start-width: var(--dds-space-4); border-radius: var(--dds-radius-md); padding: var(--dds-space-12) var(--dds-space-16); }
.site-status--danger { border-inline-start-color: var(--dds-color-status-danger); }
Expand Down
1 change: 1 addition & 0 deletions packages/site-kit/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default defineConfig({
"src/solid/__tests__/shells.test.tsx",
"src/solid/__tests__/oss-product-mark.test.tsx",
"src/solid/__tests__/sections.test.tsx",
"src/solid/__tests__/footer.test.tsx",
],
restoreMocks: true,
},
Expand Down