@remit/ui 0.0.107 → 0.0.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounted against jsdom rather than `renderToString`, since focus and clicks
|
|
3
|
+
* need a real `document`. Enter-to-activate is the browser's own behaviour and
|
|
4
|
+
* jsdom does not implement it for anchors; `detail-surface.stories.tsx` covers
|
|
5
|
+
* that in Chromium.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
9
|
+
import type { JSDOM } from "jsdom";
|
|
10
|
+
import { act, createElement, type MouseEvent } from "react";
|
|
11
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
12
|
+
import { NavLinkSurface } from "./nav-link-surface.js";
|
|
13
|
+
|
|
14
|
+
let dom: JSDOM;
|
|
15
|
+
let container: HTMLElement;
|
|
16
|
+
let root: Root;
|
|
17
|
+
|
|
18
|
+
before(async () => {
|
|
19
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
20
|
+
dom = new JSDOMCtor(
|
|
21
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
22
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
23
|
+
);
|
|
24
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
25
|
+
globalThis.document = dom.window.document;
|
|
26
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
27
|
+
globalThis.Element = dom.window.Element;
|
|
28
|
+
globalThis.MouseEvent = dom.window.MouseEvent;
|
|
29
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
30
|
+
value: dom.window.navigator,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
(
|
|
34
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
35
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
after(() => {
|
|
39
|
+
dom.window.close();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
container = dom.window.document.getElementById(
|
|
44
|
+
"root",
|
|
45
|
+
) as unknown as HTMLElement;
|
|
46
|
+
container.innerHTML = "";
|
|
47
|
+
root = createRoot(container);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
act(() => {
|
|
52
|
+
root.unmount();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function mount(props: Parameters<typeof NavLinkSurface>[0]) {
|
|
57
|
+
act(() => {
|
|
58
|
+
root.render(createElement(NavLinkSurface, props, "Daily brief"));
|
|
59
|
+
});
|
|
60
|
+
const link = container.querySelector("a");
|
|
61
|
+
assert.ok(link, "no anchor rendered");
|
|
62
|
+
return link;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function click(target: Element, init: { metaKey?: boolean } = {}) {
|
|
66
|
+
act(() => {
|
|
67
|
+
target.dispatchEvent(
|
|
68
|
+
new dom.window.MouseEvent("click", {
|
|
69
|
+
bubbles: true,
|
|
70
|
+
cancelable: true,
|
|
71
|
+
...init,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("NavLinkSurface interaction", () => {
|
|
78
|
+
it("puts a link with an href in the tab order", () => {
|
|
79
|
+
const link = mount({ href: "/mail/brief" });
|
|
80
|
+
link.focus();
|
|
81
|
+
assert.equal(dom.window.document.activeElement, link);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps a link with no href out of the tab order", () => {
|
|
85
|
+
const link = mount({});
|
|
86
|
+
link.focus();
|
|
87
|
+
assert.notEqual(dom.window.document.activeElement, link);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("passes a modified click through with its modifier intact", () => {
|
|
91
|
+
const seen: boolean[] = [];
|
|
92
|
+
const link = mount({
|
|
93
|
+
href: "/mail/brief",
|
|
94
|
+
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
seen.push(event.metaKey);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
click(link);
|
|
101
|
+
click(link, { metaKey: true });
|
|
102
|
+
|
|
103
|
+
assert.deepEqual(seen, [false, true]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("adds no click handling of its own when the caller passes none", () => {
|
|
107
|
+
const link = mount({ href: "/mail/brief" });
|
|
108
|
+
const event = new dom.window.MouseEvent("click", {
|
|
109
|
+
bubbles: true,
|
|
110
|
+
cancelable: true,
|
|
111
|
+
});
|
|
112
|
+
act(() => {
|
|
113
|
+
link.dispatchEvent(event);
|
|
114
|
+
});
|
|
115
|
+
assert.equal(event.defaultPrevented, false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { NavLinkSurface } from "./nav-link-surface.js";
|
|
6
|
+
|
|
7
|
+
describe("NavLinkSurface", () => {
|
|
8
|
+
it("renders a real anchor carrying the href", () => {
|
|
9
|
+
const html = renderToString(
|
|
10
|
+
createElement(NavLinkSurface, { href: "/mail/brief" }, "Daily brief"),
|
|
11
|
+
);
|
|
12
|
+
assert.match(html, /^<a /);
|
|
13
|
+
assert.match(html, /href="\/mail\/brief"/);
|
|
14
|
+
assert.match(html, /Daily brief/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders no href attribute when given none", () => {
|
|
18
|
+
const html = renderToString(
|
|
19
|
+
createElement(NavLinkSurface, {}, "Daily brief"),
|
|
20
|
+
);
|
|
21
|
+
assert.doesNotMatch(html, /href=/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("marks the current destination and styles it", () => {
|
|
25
|
+
const html = renderToString(
|
|
26
|
+
createElement(
|
|
27
|
+
NavLinkSurface,
|
|
28
|
+
{ href: "/mail/brief", current: "page" },
|
|
29
|
+
"Daily brief",
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
assert.match(html, /aria-current="page"/);
|
|
33
|
+
assert.match(html, /bg-accent-2-soft/);
|
|
34
|
+
assert.doesNotMatch(html, /text-fg-muted/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("styles a caller-supplied aria-current the same way", () => {
|
|
38
|
+
const html = renderToString(
|
|
39
|
+
createElement(
|
|
40
|
+
NavLinkSurface,
|
|
41
|
+
{ href: "/mail/brief", "aria-current": "page" },
|
|
42
|
+
"Daily brief",
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
assert.match(html, /aria-current="page"/);
|
|
46
|
+
assert.match(html, /bg-accent-2-soft/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("treats aria-current=false as not current", () => {
|
|
50
|
+
const html = renderToString(
|
|
51
|
+
createElement(
|
|
52
|
+
NavLinkSurface,
|
|
53
|
+
{ href: "/mail/brief", "aria-current": "false" },
|
|
54
|
+
"Daily brief",
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
assert.match(html, /text-fg-muted/);
|
|
58
|
+
assert.doesNotMatch(html, /bg-accent-2-soft/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("carries a focus ring on every variant", () => {
|
|
62
|
+
for (const variant of ["nav", "row", "inline"] as const) {
|
|
63
|
+
const html = renderToString(
|
|
64
|
+
createElement(NavLinkSurface, { href: "/x", variant }, "x"),
|
|
65
|
+
);
|
|
66
|
+
assert.match(html, /focus-visible:ring-2/, variant);
|
|
67
|
+
assert.match(html, /focus-visible:ring-ring/, variant);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rings a full-bleed row inside its own edge", () => {
|
|
72
|
+
const html = renderToString(
|
|
73
|
+
createElement(NavLinkSurface, { href: "/x", variant: "row" }, "x"),
|
|
74
|
+
);
|
|
75
|
+
assert.match(html, /focus-visible:ring-inset/);
|
|
76
|
+
assert.doesNotMatch(html, /ring-offset/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rings a nav entry outside its own edge", () => {
|
|
80
|
+
const html = renderToString(
|
|
81
|
+
createElement(NavLinkSurface, { href: "/x", variant: "nav" }, "x"),
|
|
82
|
+
);
|
|
83
|
+
assert.match(html, /focus-visible:ring-offset-1/);
|
|
84
|
+
assert.doesNotMatch(html, /ring-inset/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("underlines the inline variant on hover", () => {
|
|
88
|
+
const html = renderToString(
|
|
89
|
+
createElement(NavLinkSurface, { href: "/x", variant: "inline" }, "x"),
|
|
90
|
+
);
|
|
91
|
+
assert.match(html, /hover:underline/);
|
|
92
|
+
assert.match(html, /text-accent/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("lets a caller's className override the variant's own colours", () => {
|
|
96
|
+
const html = renderToString(
|
|
97
|
+
createElement(
|
|
98
|
+
NavLinkSurface,
|
|
99
|
+
{ href: "/x", className: "text-danger" },
|
|
100
|
+
"x",
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
assert.match(html, /text-danger/);
|
|
104
|
+
assert.doesNotMatch(html, /text-fg-muted/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("passes anchor attributes straight through", () => {
|
|
108
|
+
const html = renderToString(
|
|
109
|
+
createElement(
|
|
110
|
+
NavLinkSurface,
|
|
111
|
+
{
|
|
112
|
+
href: "https://example.com",
|
|
113
|
+
target: "_blank",
|
|
114
|
+
rel: "noopener noreferrer",
|
|
115
|
+
"data-testid": "nav-link",
|
|
116
|
+
title: "Example",
|
|
117
|
+
},
|
|
118
|
+
"x",
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
assert.match(html, /target="_blank"/);
|
|
122
|
+
assert.match(html, /rel="noopener noreferrer"/);
|
|
123
|
+
assert.match(html, /data-testid="nav-link"/);
|
|
124
|
+
assert.match(html, /title="Example"/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { AnchorHTMLAttributes, AriaAttributes, Ref } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export type NavLinkSurfaceVariant = "nav" | "row" | "inline";
|
|
5
|
+
|
|
6
|
+
/** Every `aria-current` value ARIA defines, minus the absent case. */
|
|
7
|
+
export type NavLinkCurrent = NonNullable<AriaAttributes["aria-current"]>;
|
|
8
|
+
|
|
9
|
+
export interface NavLinkSurfaceProps
|
|
10
|
+
extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
11
|
+
variant?: NavLinkSurfaceVariant;
|
|
12
|
+
/**
|
|
13
|
+
* Which kind of "current" this destination is. Sets `aria-current` and turns
|
|
14
|
+
* on the current-state styling together, so the two cannot disagree. A
|
|
15
|
+
* caller that already sets `aria-current` — the router binding does — gets
|
|
16
|
+
* the same styling without passing this.
|
|
17
|
+
*/
|
|
18
|
+
current?: NavLinkCurrent;
|
|
19
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
20
|
+
/** `data-status` is what the router's link props mark an active link with. */
|
|
21
|
+
[key: `data-${string}`]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const OFFSET_RING =
|
|
25
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface";
|
|
26
|
+
|
|
27
|
+
/** A full-bleed row has no margin to spend on an offset ring. */
|
|
28
|
+
const INSET_RING =
|
|
29
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset";
|
|
30
|
+
|
|
31
|
+
const variants: Record<
|
|
32
|
+
NavLinkSurfaceVariant,
|
|
33
|
+
{ base: string; rest: string; current: string }
|
|
34
|
+
> = {
|
|
35
|
+
nav: {
|
|
36
|
+
base: cn(
|
|
37
|
+
"flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm transition-colors",
|
|
38
|
+
OFFSET_RING,
|
|
39
|
+
),
|
|
40
|
+
rest: "text-fg-muted hover:bg-surface hover:text-fg",
|
|
41
|
+
current: "bg-accent-2-soft font-medium text-accent-2",
|
|
42
|
+
},
|
|
43
|
+
row: {
|
|
44
|
+
base: cn(
|
|
45
|
+
"relative flex w-full items-start text-left transition-colors",
|
|
46
|
+
INSET_RING,
|
|
47
|
+
),
|
|
48
|
+
rest: "hover:bg-surface-sunken",
|
|
49
|
+
current: "bg-accent-2-soft",
|
|
50
|
+
},
|
|
51
|
+
inline: {
|
|
52
|
+
base: cn(
|
|
53
|
+
"rounded-sm underline-offset-2 transition-colors hover:underline",
|
|
54
|
+
OFFSET_RING,
|
|
55
|
+
),
|
|
56
|
+
rest: "text-accent hover:text-accent-hover",
|
|
57
|
+
current: "font-medium text-accent",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function isCurrent(value: NavLinkCurrent | undefined): boolean {
|
|
62
|
+
return value !== undefined && value !== false && value !== "false";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The design system's navigation link: a real `<a>`, so middle-click, cmd-click,
|
|
67
|
+
* "copy link address" and the browser's own Enter handling all work without a
|
|
68
|
+
* single handler of ours. It carries appearance and accessible state only —
|
|
69
|
+
* where the link goes is the router binding's business, and this package imports
|
|
70
|
+
* no router.
|
|
71
|
+
*/
|
|
72
|
+
export function NavLinkSurface({
|
|
73
|
+
variant = "nav",
|
|
74
|
+
current,
|
|
75
|
+
"aria-current": ariaCurrent,
|
|
76
|
+
className,
|
|
77
|
+
children,
|
|
78
|
+
ref,
|
|
79
|
+
...props
|
|
80
|
+
}: NavLinkSurfaceProps) {
|
|
81
|
+
const resolved = current ?? ariaCurrent;
|
|
82
|
+
const style = variants[variant];
|
|
83
|
+
return (
|
|
84
|
+
<a
|
|
85
|
+
ref={ref}
|
|
86
|
+
aria-current={resolved}
|
|
87
|
+
className={cn(
|
|
88
|
+
style.base,
|
|
89
|
+
isCurrent(resolved) ? style.current : style.rest,
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
{...props}
|
|
93
|
+
>
|
|
94
|
+
{children}
|
|
95
|
+
</a>
|
|
96
|
+
);
|
|
97
|
+
}
|
package/src/index.ts
CHANGED