@tangle-network/ui 11.22.3 → 11.24.0
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/CHANGELOG.md +12 -0
- package/dist/primitives/pagination.d.ts +47 -9
- package/dist/primitives/pagination.js +50 -20
- package/dist/primitives/tag.d.ts +6 -0
- package/dist/primitives/tag.js +22 -7
- package/package.json +1 -1
- package/src/primitives/chip.test.tsx +22 -0
- package/src/primitives/pagination-alert.stories.tsx +35 -0
- package/src/primitives/pagination-alert.test.tsx +90 -0
- package/src/primitives/pagination.tsx +141 -40
- package/src/primitives/tag-chip.stories.tsx +24 -0
- package/src/primitives/tag.tsx +33 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.24.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6098fc6: Pagination: add a link mode for lists paged by URL. Pass `hrefFor` (zero-based page in, URL out) instead of `onPageChange` and each enabled control renders as a link, through `linkComponent` (default `"a"`) when a router link is needed. The current page carries `aria-current="page"`, and a disabled previous or next renders as a `<span aria-disabled="true">` with no `href`. `PaginationProps` is now a union of `PaginationButtonProps` and `PaginationLinkProps`; button mode is unchanged.
|
|
8
|
+
|
|
9
|
+
## 11.23.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 57f4e3d: `Tag` takes `wrap` to break a long label across lines instead of truncating it, for text that must be read in full without a hover (model-written part names on a phone or on paper).
|
|
14
|
+
|
|
3
15
|
## 11.22.3
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1,19 +1,57 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
//#region src/primitives/pagination.d.ts
|
|
3
|
+
interface PaginationBaseProps extends Omit<React.HTMLAttributes<HTMLElement>, "onChange" | "children"> {
|
|
4
|
+
/** The current page, zero-based. */
|
|
5
|
+
page: number;
|
|
6
|
+
pageCount: number;
|
|
7
|
+
/** Pages shown on each side of the current one before an ellipsis. */
|
|
8
|
+
siblings?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface PaginationButtonProps extends PaginationBaseProps {
|
|
11
|
+
onPageChange: (page: number) => void;
|
|
12
|
+
hrefFor?: never;
|
|
13
|
+
linkComponent?: never;
|
|
14
|
+
}
|
|
15
|
+
export interface PaginationLinkProps extends PaginationBaseProps {
|
|
16
|
+
/** The URL of a page, given the zero-based page. */
|
|
17
|
+
hrefFor: (page: number) => string;
|
|
18
|
+
/** Renders each enabled control. Receives `href`; defaults to `"a"`. */
|
|
19
|
+
linkComponent?: React.ElementType;
|
|
20
|
+
/**
|
|
21
|
+
* Called with the target page when a link to another page is clicked, unless
|
|
22
|
+
* the click opens it in another tab or window.
|
|
23
|
+
*/
|
|
24
|
+
onPageChange?: (page: number) => void;
|
|
25
|
+
}
|
|
3
26
|
/**
|
|
4
27
|
* Page controls for a paged list or table: previous, the page numbers around
|
|
5
28
|
* the current one with the first and last always reachable, and next.
|
|
6
29
|
*
|
|
7
30
|
* Pages are zero-based in the API and one-based on screen. With one page or
|
|
8
31
|
* none there is nothing to choose, so it renders nothing.
|
|
32
|
+
*
|
|
33
|
+
* Give `onPageChange` to render buttons, or `hrefFor` to render links for a
|
|
34
|
+
* list paged by URL. In link mode each enabled control is rendered by
|
|
35
|
+
* `linkComponent` (an `<a>` by default) with `href`, `className`,
|
|
36
|
+
* `aria-label`, `aria-current` and children; a disabled previous or next is a
|
|
37
|
+
* `<span aria-disabled="true">` with no `href`. A router whose link takes `to`
|
|
38
|
+
* instead of `href` needs a small adapter:
|
|
39
|
+
*
|
|
40
|
+
* ```tsx
|
|
41
|
+
* import { Link } from "react-router";
|
|
42
|
+
*
|
|
43
|
+
* const RouterLink = ({ href, ...props }: React.ComponentProps<"a">) => (
|
|
44
|
+
* <Link to={href ?? ""} {...props} />
|
|
45
|
+
* );
|
|
46
|
+
*
|
|
47
|
+
* <Pagination
|
|
48
|
+
* page={page}
|
|
49
|
+
* pageCount={pageCount}
|
|
50
|
+
* hrefFor={(p) => `?page=${p + 1}`}
|
|
51
|
+
* linkComponent={RouterLink}
|
|
52
|
+
* />
|
|
53
|
+
* ```
|
|
9
54
|
*/
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
page: number;
|
|
13
|
-
pageCount: number;
|
|
14
|
-
onPageChange: (page: number) => void;
|
|
15
|
-
/** Pages shown on each side of the current one before an ellipsis. */
|
|
16
|
-
siblings?: number;
|
|
17
|
-
}
|
|
18
|
-
export declare function Pagination({ page, pageCount, onPageChange, siblings, className, "aria-label": ariaLabel, ...rest }: PaginationProps): React.JSX.Element | null;
|
|
55
|
+
export type PaginationProps = PaginationButtonProps | PaginationLinkProps;
|
|
56
|
+
export declare function Pagination({ page, pageCount, onPageChange, hrefFor, linkComponent: LinkComponent, siblings, className, "aria-label": ariaLabel, ...rest }: PaginationProps): React.JSX.Element | null;
|
|
19
57
|
//#endregion
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cn } from "../lib/utils.js";
|
|
2
2
|
import { buttonVariants } from "./button.js";
|
|
3
3
|
import "react";
|
|
4
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
6
6
|
//#region src/primitives/pagination.tsx
|
|
7
7
|
/**
|
|
@@ -24,13 +24,46 @@ const pageButton = cn(buttonVariants({
|
|
|
24
24
|
variant: "ghost",
|
|
25
25
|
size: "sm"
|
|
26
26
|
}), "min-w-8 px-2 tabular-nums");
|
|
27
|
-
|
|
27
|
+
/** A click that opens the link in another tab or window leaves this page where it is. */
|
|
28
|
+
function opensElsewhere(event) {
|
|
29
|
+
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
|
30
|
+
}
|
|
31
|
+
function Pagination({ page, pageCount, onPageChange, hrefFor, linkComponent: LinkComponent = "a", siblings = 1, className, "aria-label": ariaLabel = "Pagination", ...rest }) {
|
|
28
32
|
if (pageCount <= 1) return null;
|
|
29
33
|
const last = pageCount - 1;
|
|
30
34
|
const current = Math.min(Math.max(page, 0), last);
|
|
31
35
|
const go = (next) => {
|
|
32
36
|
const target = Math.min(Math.max(next, 0), last);
|
|
33
|
-
if (target !== current) onPageChange(target);
|
|
37
|
+
if (target !== current) onPageChange?.(target);
|
|
38
|
+
};
|
|
39
|
+
const control = ({ target, label, className: controlClass, disabled, current: isCurrent, children }) => {
|
|
40
|
+
const ariaCurrent = isCurrent ? "page" : void 0;
|
|
41
|
+
if (!hrefFor) return /* @__PURE__ */ jsx("button", {
|
|
42
|
+
type: "button",
|
|
43
|
+
"aria-label": label,
|
|
44
|
+
"aria-current": ariaCurrent,
|
|
45
|
+
disabled,
|
|
46
|
+
onClick: () => go(target),
|
|
47
|
+
className: controlClass,
|
|
48
|
+
children
|
|
49
|
+
});
|
|
50
|
+
if (disabled) return /* @__PURE__ */ jsx("span", {
|
|
51
|
+
role: "link",
|
|
52
|
+
"aria-label": label,
|
|
53
|
+
"aria-disabled": "true",
|
|
54
|
+
className: cn(controlClass, "pointer-events-none"),
|
|
55
|
+
children
|
|
56
|
+
});
|
|
57
|
+
return /* @__PURE__ */ jsx(LinkComponent, {
|
|
58
|
+
href: hrefFor(target),
|
|
59
|
+
"aria-label": label,
|
|
60
|
+
"aria-current": ariaCurrent,
|
|
61
|
+
onClick: onPageChange ? (event) => {
|
|
62
|
+
if (!opensElsewhere(event)) go(target);
|
|
63
|
+
} : void 0,
|
|
64
|
+
className: controlClass,
|
|
65
|
+
children
|
|
66
|
+
});
|
|
34
67
|
};
|
|
35
68
|
return /* @__PURE__ */ jsx("nav", {
|
|
36
69
|
"aria-label": ariaLabel,
|
|
@@ -39,22 +72,20 @@ function Pagination({ page, pageCount, onPageChange, siblings = 1, className, "a
|
|
|
39
72
|
children: /* @__PURE__ */ jsxs("ul", {
|
|
40
73
|
className: "flex flex-wrap items-center gap-1",
|
|
41
74
|
children: [
|
|
42
|
-
/* @__PURE__ */ jsx("li", { children:
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
/* @__PURE__ */ jsx("li", { children: control({
|
|
76
|
+
target: current - 1,
|
|
77
|
+
label: "Previous page",
|
|
45
78
|
disabled: current === 0,
|
|
46
|
-
onClick: () => go(current - 1),
|
|
47
79
|
className: cn(pageButton, "gap-1 pl-1.5"),
|
|
48
|
-
children: [/* @__PURE__ */ jsx(ChevronLeft, { "aria-hidden": "true" }), /* @__PURE__ */ jsx("span", {
|
|
80
|
+
children: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ChevronLeft, { "aria-hidden": "true" }), /* @__PURE__ */ jsx("span", {
|
|
49
81
|
className: "hidden sm:inline",
|
|
50
82
|
children: "Previous"
|
|
51
|
-
})]
|
|
83
|
+
})] })
|
|
52
84
|
}) }),
|
|
53
|
-
pageSlots(current, pageCount, siblings).map((slot) => typeof slot === "number" ? /* @__PURE__ */ jsx("li", { children:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
onClick: () => go(slot),
|
|
85
|
+
pageSlots(current, pageCount, siblings).map((slot) => typeof slot === "number" ? /* @__PURE__ */ jsx("li", { children: control({
|
|
86
|
+
target: slot,
|
|
87
|
+
label: `Page ${slot + 1}`,
|
|
88
|
+
current: slot === current,
|
|
58
89
|
className: cn(pageButton, slot === current && "border-border bg-card font-semibold text-foreground hover:bg-card"),
|
|
59
90
|
children: slot + 1
|
|
60
91
|
}) }, slot) : /* @__PURE__ */ jsx("li", {
|
|
@@ -62,16 +93,15 @@ function Pagination({ page, pageCount, onPageChange, siblings = 1, className, "a
|
|
|
62
93
|
className: "flex min-w-8 justify-center text-sm text-muted-foreground",
|
|
63
94
|
children: "…"
|
|
64
95
|
}, slot)),
|
|
65
|
-
/* @__PURE__ */ jsx("li", { children:
|
|
66
|
-
|
|
67
|
-
|
|
96
|
+
/* @__PURE__ */ jsx("li", { children: control({
|
|
97
|
+
target: current + 1,
|
|
98
|
+
label: "Next page",
|
|
68
99
|
disabled: current === last,
|
|
69
|
-
onClick: () => go(current + 1),
|
|
70
100
|
className: cn(pageButton, "gap-1 pr-1.5"),
|
|
71
|
-
children: [/* @__PURE__ */ jsx("span", {
|
|
101
|
+
children: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
|
|
72
102
|
className: "hidden sm:inline",
|
|
73
103
|
children: "Next"
|
|
74
|
-
}), /* @__PURE__ */ jsx(ChevronRight, { "aria-hidden": "true" })]
|
|
104
|
+
}), /* @__PURE__ */ jsx(ChevronRight, { "aria-hidden": "true" })] })
|
|
75
105
|
}) })
|
|
76
106
|
]
|
|
77
107
|
})
|
package/dist/primitives/tag.d.ts
CHANGED
|
@@ -30,6 +30,12 @@ export interface TagProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "c
|
|
|
30
30
|
* the label is a string; pass it when the label is not plain text.
|
|
31
31
|
*/
|
|
32
32
|
removeLabel?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Wraps a long label onto more lines instead of truncating it. Use it where
|
|
35
|
+
* the full text matters and no hover is available to read a `title`, such
|
|
36
|
+
* as model-written part names read on a phone or on paper.
|
|
37
|
+
*/
|
|
38
|
+
wrap?: boolean;
|
|
33
39
|
}
|
|
34
40
|
declare const Tag: React.ForwardRefExoticComponent<TagProps & React.RefAttributes<HTMLSpanElement>>;
|
|
35
41
|
//#endregion
|
package/dist/primitives/tag.js
CHANGED
|
@@ -6,23 +6,38 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { X } from "lucide-react";
|
|
7
7
|
//#region src/primitives/tag.tsx
|
|
8
8
|
const SIZE = {
|
|
9
|
-
sm: "
|
|
10
|
-
md: "
|
|
9
|
+
sm: "gap-1 px-2 text-xs [&_svg]:size-3",
|
|
10
|
+
md: "gap-1.5 px-2.5 text-sm [&_svg]:size-3.5"
|
|
11
11
|
};
|
|
12
|
-
const
|
|
12
|
+
const WRAP = {
|
|
13
|
+
sm: {
|
|
14
|
+
box: "items-start",
|
|
15
|
+
label: "py-[3px]",
|
|
16
|
+
icon: "h-[22px] items-center"
|
|
17
|
+
},
|
|
18
|
+
md: {
|
|
19
|
+
box: "items-start",
|
|
20
|
+
label: "py-[3px]",
|
|
21
|
+
icon: "h-[26px] items-center"
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const Tag = React.forwardRef(({ className, children, tone = "neutral", emphasis = "soft", size = "sm", icon, onRemove, removeLabel, wrap = false, ...props }, ref) => {
|
|
13
25
|
const classes = TONE_CLASSES[tone];
|
|
14
26
|
const label = removeLabel ?? (typeof children === "string" ? `Remove ${children}` : "Remove");
|
|
15
27
|
return /* @__PURE__ */ jsxs("span", {
|
|
16
28
|
ref,
|
|
17
|
-
className: cn("inline-flex min-w-0 max-w-full shrink-0
|
|
29
|
+
className: cn("inline-flex min-w-0 max-w-full shrink-0 rounded-md border font-medium", SIZE[size], wrap ? WRAP[size].box : ["items-center", size === "sm" ? "h-6" : "h-7"], emphasis === "outline" ? classes.outline : classes.surface, onRemove && (size === "sm" ? "pr-0.5" : "pr-1"), className),
|
|
18
30
|
...props,
|
|
19
31
|
children: [
|
|
20
32
|
icon ? /* @__PURE__ */ jsx("span", {
|
|
21
33
|
"aria-hidden": "true",
|
|
22
|
-
className: cn("inline-flex shrink-0", classes.icon),
|
|
34
|
+
className: cn("inline-flex shrink-0", wrap && WRAP[size].icon, classes.icon),
|
|
23
35
|
children: icon
|
|
24
36
|
}) : null,
|
|
25
|
-
/* @__PURE__ */ jsx("span", {
|
|
37
|
+
wrap ? /* @__PURE__ */ jsx("span", {
|
|
38
|
+
className: cn("min-w-0 [overflow-wrap:anywhere]", WRAP[size].label),
|
|
39
|
+
children
|
|
40
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
26
41
|
className: "min-w-0 truncate",
|
|
27
42
|
title: typeof children === "string" ? children : void 0,
|
|
28
43
|
children
|
|
@@ -31,7 +46,7 @@ const Tag = React.forwardRef(({ className, children, tone = "neutral", emphasis
|
|
|
31
46
|
type: "button",
|
|
32
47
|
"aria-label": label,
|
|
33
48
|
onClick: onRemove,
|
|
34
|
-
className: cn("relative inline-flex shrink-0 items-center justify-center rounded-sm opacity-70 transition-[opacity,background-color] duration-150 ease-out hover:bg-[color-mix(in_srgb,currentColor_14%,transparent)] hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']", size === "sm" ? "size-5" : "size-6", focusRing),
|
|
49
|
+
className: cn("relative inline-flex shrink-0 items-center justify-center rounded-sm opacity-70 transition-[opacity,background-color] duration-150 ease-out hover:bg-[color-mix(in_srgb,currentColor_14%,transparent)] hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']", size === "sm" ? "size-5" : "size-6", wrap && "mt-px", focusRing),
|
|
35
50
|
children: /* @__PURE__ */ jsx(X, { "aria-hidden": "true" })
|
|
36
51
|
}) : null
|
|
37
52
|
]
|
package/package.json
CHANGED
|
@@ -124,6 +124,28 @@ describe("Tag", () => {
|
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
describe("Tag wrap", () => {
|
|
128
|
+
const LONG = "Adafruit-BME680-Temperature-Humidity-Pressure-Gas-Sensor-Breakout";
|
|
129
|
+
|
|
130
|
+
it("truncates a long label by default and names it in a title", () => {
|
|
131
|
+
render(<Tag>{LONG}</Tag>);
|
|
132
|
+
const label = screen.getByText(LONG);
|
|
133
|
+
expect(label.className).toContain("truncate");
|
|
134
|
+
expect(label.getAttribute("title")).toBe(LONG);
|
|
135
|
+
expect(label.parentElement?.className).toContain("h-6");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("wraps a long label across lines when asked, aligned to its first line", () => {
|
|
139
|
+
render(<Tag wrap size="md">{LONG}</Tag>);
|
|
140
|
+
const label = screen.getByText(LONG);
|
|
141
|
+
expect(label.className).not.toContain("truncate");
|
|
142
|
+
expect(label.className).toContain("[overflow-wrap:anywhere]");
|
|
143
|
+
expect(label.className).toContain("py-[3px]");
|
|
144
|
+
expect(label.parentElement?.className).toContain("items-start");
|
|
145
|
+
expect(label.parentElement?.className).not.toMatch(/(^|\s)h-7(\s|$)/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
127
149
|
describe("IconTile", () => {
|
|
128
150
|
it("falls back from a broken image to the glyph, then to initials", () => {
|
|
129
151
|
const { container, rerender } = render(<IconTile src="https://example.invalid/logo.png" name="Acme Robotics" />);
|
|
@@ -25,6 +25,41 @@ export const PaginationLengths: StoryObj = {
|
|
|
25
25
|
),
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Keeps the story in place; a real app passes its router's link. */
|
|
29
|
+
function StoryLink({ onClick, ...props }: React.ComponentProps<'a'>) {
|
|
30
|
+
return (
|
|
31
|
+
<a
|
|
32
|
+
{...props}
|
|
33
|
+
onClick={(event) => {
|
|
34
|
+
event.preventDefault()
|
|
35
|
+
onClick?.(event)
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function LinkedTable({ pageCount }: { pageCount: number }) {
|
|
42
|
+
const [page, setPage] = React.useState(0)
|
|
43
|
+
return (
|
|
44
|
+
<Pagination
|
|
45
|
+
page={page}
|
|
46
|
+
pageCount={pageCount}
|
|
47
|
+
hrefFor={(p) => `?page=${p + 1}`}
|
|
48
|
+
linkComponent={StoryLink}
|
|
49
|
+
onPageChange={setPage}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const PaginationLinks: StoryObj = {
|
|
55
|
+
render: () => (
|
|
56
|
+
<div className="flex flex-col gap-6 bg-background p-6 text-foreground">
|
|
57
|
+
<LinkedTable pageCount={4} />
|
|
58
|
+
<LinkedTable pageCount={48} />
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
28
63
|
export const AlertTones: StoryObj = {
|
|
29
64
|
render: () => (
|
|
30
65
|
<div className="flex max-w-xl flex-col gap-3 bg-background p-6 text-foreground">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render, screen } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import type * as React from "react";
|
|
3
4
|
import { describe, expect, it, vi } from "vitest";
|
|
4
5
|
import { Alert, AlertDescription, AlertTitle } from "./alert";
|
|
5
6
|
import { Pagination, pageSlots } from "./pagination";
|
|
@@ -63,6 +64,95 @@ describe("Pagination", () => {
|
|
|
63
64
|
});
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
describe("Pagination link mode", () => {
|
|
68
|
+
const hrefFor = (page: number) => `?page=${page + 1}`;
|
|
69
|
+
|
|
70
|
+
it("renders links to one-based URLs from zero-based pages", () => {
|
|
71
|
+
render(<Pagination page={4} pageCount={12} hrefFor={hrefFor} />);
|
|
72
|
+
|
|
73
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
74
|
+
expect(screen.getByRole("link", { name: "Previous page" })).toHaveAttribute("href", "?page=4");
|
|
75
|
+
expect(screen.getByRole("link", { name: "Next page" })).toHaveAttribute("href", "?page=6");
|
|
76
|
+
expect(screen.getByRole("link", { name: "Page 1" })).toHaveAttribute("href", "?page=1");
|
|
77
|
+
expect(screen.getByRole("link", { name: "Page 12" })).toHaveAttribute("href", "?page=12");
|
|
78
|
+
|
|
79
|
+
const current = screen.getByRole("link", { name: "Page 5" });
|
|
80
|
+
expect(current.tagName).toBe("A");
|
|
81
|
+
expect(current).toHaveAttribute("href", "?page=5");
|
|
82
|
+
expect(current).toHaveAttribute("aria-current", "page");
|
|
83
|
+
expect(screen.getByRole("link", { name: "Page 4" })).not.toHaveAttribute("aria-current");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders a disabled previous or next as a span without an href", () => {
|
|
87
|
+
const { rerender } = render(<Pagination page={0} pageCount={3} hrefFor={hrefFor} />);
|
|
88
|
+
const previous = screen.getByRole("link", { name: "Previous page" });
|
|
89
|
+
expect(previous.tagName).toBe("SPAN");
|
|
90
|
+
expect(previous).toHaveAttribute("aria-disabled", "true");
|
|
91
|
+
expect(previous).not.toHaveAttribute("href");
|
|
92
|
+
expect(previous.className).toContain("aria-disabled:opacity-50");
|
|
93
|
+
expect(screen.getByRole("link", { name: "Next page" })).toHaveAttribute("href", "?page=2");
|
|
94
|
+
|
|
95
|
+
rerender(<Pagination page={2} pageCount={3} hrefFor={hrefFor} />);
|
|
96
|
+
const next = screen.getByRole("link", { name: "Next page" });
|
|
97
|
+
expect(next.tagName).toBe("SPAN");
|
|
98
|
+
expect(next).toHaveAttribute("aria-disabled", "true");
|
|
99
|
+
expect(next).not.toHaveAttribute("href");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("renders each enabled control with a custom link component", () => {
|
|
103
|
+
const received: string[] = [];
|
|
104
|
+
const RouterLink = ({ href, ...props }: React.ComponentProps<"a">) => {
|
|
105
|
+
received.push(href ?? "");
|
|
106
|
+
return <a data-router-link="" href={href} {...props} />;
|
|
107
|
+
};
|
|
108
|
+
render(<Pagination page={1} pageCount={3} hrefFor={hrefFor} linkComponent={RouterLink} />);
|
|
109
|
+
|
|
110
|
+
expect(received).toEqual(["?page=1", "?page=1", "?page=2", "?page=3", "?page=3"]);
|
|
111
|
+
const current = screen.getByRole("link", { name: "Page 2" });
|
|
112
|
+
expect(current).toHaveAttribute("data-router-link");
|
|
113
|
+
expect(current).toHaveAttribute("aria-current", "page");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("reports a plain click on another page and ignores the current page and new-tab clicks", async () => {
|
|
117
|
+
const user = userEvent.setup();
|
|
118
|
+
const onPageChange = vi.fn();
|
|
119
|
+
render(
|
|
120
|
+
<Pagination
|
|
121
|
+
page={1}
|
|
122
|
+
pageCount={3}
|
|
123
|
+
hrefFor={hrefFor}
|
|
124
|
+
linkComponent={({ onClick, ...props }: React.ComponentProps<"a">) => (
|
|
125
|
+
<a
|
|
126
|
+
{...props}
|
|
127
|
+
onClick={(event) => {
|
|
128
|
+
onClick?.(event);
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
onPageChange={onPageChange}
|
|
134
|
+
/>,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await user.click(screen.getByRole("link", { name: "Next page" }));
|
|
138
|
+
await user.click(screen.getByRole("link", { name: "Page 2" }));
|
|
139
|
+
await user.keyboard("{Meta>}");
|
|
140
|
+
await user.click(screen.getByRole("link", { name: "Page 1" }));
|
|
141
|
+
await user.keyboard("{/Meta}");
|
|
142
|
+
expect(onPageChange.mock.calls).toEqual([[2]]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("requires onPageChange or hrefFor, and a link component only with hrefFor", () => {
|
|
146
|
+
const rejected = [
|
|
147
|
+
// @ts-expect-error neither onPageChange nor hrefFor
|
|
148
|
+
() => <Pagination page={0} pageCount={2} />,
|
|
149
|
+
// @ts-expect-error linkComponent without hrefFor
|
|
150
|
+
() => <Pagination page={0} pageCount={2} onPageChange={vi.fn()} linkComponent="a" />,
|
|
151
|
+
];
|
|
152
|
+
expect(rejected).toHaveLength(2);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
66
156
|
describe("Alert", () => {
|
|
67
157
|
it("paints a tone from the matched Brand status triple", () => {
|
|
68
158
|
render(
|
|
@@ -3,23 +3,64 @@ import * as React from "react";
|
|
|
3
3
|
import { cn } from "../lib/utils";
|
|
4
4
|
import { buttonVariants } from "./button";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
* Page controls for a paged list or table: previous, the page numbers around
|
|
8
|
-
* the current one with the first and last always reachable, and next.
|
|
9
|
-
*
|
|
10
|
-
* Pages are zero-based in the API and one-based on screen. With one page or
|
|
11
|
-
* none there is nothing to choose, so it renders nothing.
|
|
12
|
-
*/
|
|
13
|
-
export interface PaginationProps
|
|
6
|
+
interface PaginationBaseProps
|
|
14
7
|
extends Omit<React.HTMLAttributes<HTMLElement>, "onChange" | "children"> {
|
|
15
8
|
/** The current page, zero-based. */
|
|
16
9
|
page: number;
|
|
17
10
|
pageCount: number;
|
|
18
|
-
onPageChange: (page: number) => void;
|
|
19
11
|
/** Pages shown on each side of the current one before an ellipsis. */
|
|
20
12
|
siblings?: number;
|
|
21
13
|
}
|
|
22
14
|
|
|
15
|
+
export interface PaginationButtonProps extends PaginationBaseProps {
|
|
16
|
+
onPageChange: (page: number) => void;
|
|
17
|
+
hrefFor?: never;
|
|
18
|
+
linkComponent?: never;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PaginationLinkProps extends PaginationBaseProps {
|
|
22
|
+
/** The URL of a page, given the zero-based page. */
|
|
23
|
+
hrefFor: (page: number) => string;
|
|
24
|
+
/** Renders each enabled control. Receives `href`; defaults to `"a"`. */
|
|
25
|
+
linkComponent?: React.ElementType;
|
|
26
|
+
/**
|
|
27
|
+
* Called with the target page when a link to another page is clicked, unless
|
|
28
|
+
* the click opens it in another tab or window.
|
|
29
|
+
*/
|
|
30
|
+
onPageChange?: (page: number) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Page controls for a paged list or table: previous, the page numbers around
|
|
35
|
+
* the current one with the first and last always reachable, and next.
|
|
36
|
+
*
|
|
37
|
+
* Pages are zero-based in the API and one-based on screen. With one page or
|
|
38
|
+
* none there is nothing to choose, so it renders nothing.
|
|
39
|
+
*
|
|
40
|
+
* Give `onPageChange` to render buttons, or `hrefFor` to render links for a
|
|
41
|
+
* list paged by URL. In link mode each enabled control is rendered by
|
|
42
|
+
* `linkComponent` (an `<a>` by default) with `href`, `className`,
|
|
43
|
+
* `aria-label`, `aria-current` and children; a disabled previous or next is a
|
|
44
|
+
* `<span aria-disabled="true">` with no `href`. A router whose link takes `to`
|
|
45
|
+
* instead of `href` needs a small adapter:
|
|
46
|
+
*
|
|
47
|
+
* ```tsx
|
|
48
|
+
* import { Link } from "react-router";
|
|
49
|
+
*
|
|
50
|
+
* const RouterLink = ({ href, ...props }: React.ComponentProps<"a">) => (
|
|
51
|
+
* <Link to={href ?? ""} {...props} />
|
|
52
|
+
* );
|
|
53
|
+
*
|
|
54
|
+
* <Pagination
|
|
55
|
+
* page={page}
|
|
56
|
+
* pageCount={pageCount}
|
|
57
|
+
* hrefFor={(p) => `?page=${p + 1}`}
|
|
58
|
+
* linkComponent={RouterLink}
|
|
59
|
+
* />
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export type PaginationProps = PaginationButtonProps | PaginationLinkProps;
|
|
63
|
+
|
|
23
64
|
type PageSlot = number | "gap-start" | "gap-end";
|
|
24
65
|
|
|
25
66
|
/**
|
|
@@ -43,10 +84,26 @@ export function pageSlots(page: number, pageCount: number, siblings = 1): PageSl
|
|
|
43
84
|
|
|
44
85
|
const pageButton = cn(buttonVariants({ variant: "ghost", size: "sm" }), "min-w-8 px-2 tabular-nums");
|
|
45
86
|
|
|
87
|
+
/** A click that opens the link in another tab or window leaves this page where it is. */
|
|
88
|
+
function opensElsewhere(event: React.MouseEvent) {
|
|
89
|
+
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface ControlProps {
|
|
93
|
+
target: number;
|
|
94
|
+
label: string;
|
|
95
|
+
className: string;
|
|
96
|
+
disabled?: boolean;
|
|
97
|
+
current?: boolean;
|
|
98
|
+
children: React.ReactNode;
|
|
99
|
+
}
|
|
100
|
+
|
|
46
101
|
export function Pagination({
|
|
47
102
|
page,
|
|
48
103
|
pageCount,
|
|
49
104
|
onPageChange,
|
|
105
|
+
hrefFor,
|
|
106
|
+
linkComponent: LinkComponent = "a",
|
|
50
107
|
siblings = 1,
|
|
51
108
|
className,
|
|
52
109
|
"aria-label": ariaLabel = "Pagination",
|
|
@@ -58,40 +115,82 @@ export function Pagination({
|
|
|
58
115
|
const current = Math.min(Math.max(page, 0), last);
|
|
59
116
|
const go = (next: number) => {
|
|
60
117
|
const target = Math.min(Math.max(next, 0), last);
|
|
61
|
-
if (target !== current) onPageChange(target);
|
|
118
|
+
if (target !== current) onPageChange?.(target);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const control = ({ target, label, className: controlClass, disabled, current: isCurrent, children }: ControlProps) => {
|
|
122
|
+
const ariaCurrent = isCurrent ? ("page" as const) : undefined;
|
|
123
|
+
if (!hrefFor) {
|
|
124
|
+
return (
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
aria-label={label}
|
|
128
|
+
aria-current={ariaCurrent}
|
|
129
|
+
disabled={disabled}
|
|
130
|
+
onClick={() => go(target)}
|
|
131
|
+
className={controlClass}
|
|
132
|
+
>
|
|
133
|
+
{children}
|
|
134
|
+
</button>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (disabled) {
|
|
138
|
+
return (
|
|
139
|
+
<span role="link" aria-label={label} aria-disabled="true" className={cn(controlClass, "pointer-events-none")}>
|
|
140
|
+
{children}
|
|
141
|
+
</span>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return (
|
|
145
|
+
<LinkComponent
|
|
146
|
+
href={hrefFor(target)}
|
|
147
|
+
aria-label={label}
|
|
148
|
+
aria-current={ariaCurrent}
|
|
149
|
+
onClick={
|
|
150
|
+
onPageChange
|
|
151
|
+
? (event: React.MouseEvent) => {
|
|
152
|
+
if (!opensElsewhere(event)) go(target);
|
|
153
|
+
}
|
|
154
|
+
: undefined
|
|
155
|
+
}
|
|
156
|
+
className={controlClass}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
</LinkComponent>
|
|
160
|
+
);
|
|
62
161
|
};
|
|
63
162
|
|
|
64
163
|
return (
|
|
65
164
|
<nav aria-label={ariaLabel} className={cn("flex items-center", className)} {...rest}>
|
|
66
165
|
<ul className="flex flex-wrap items-center gap-1">
|
|
67
166
|
<li>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
disabled
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
167
|
+
{control({
|
|
168
|
+
target: current - 1,
|
|
169
|
+
label: "Previous page",
|
|
170
|
+
disabled: current === 0,
|
|
171
|
+
className: cn(pageButton, "gap-1 pl-1.5"),
|
|
172
|
+
children: (
|
|
173
|
+
<>
|
|
174
|
+
<ChevronLeft aria-hidden="true" />
|
|
175
|
+
<span className="hidden sm:inline">Previous</span>
|
|
176
|
+
</>
|
|
177
|
+
),
|
|
178
|
+
})}
|
|
78
179
|
</li>
|
|
79
180
|
{pageSlots(current, pageCount, siblings).map((slot) =>
|
|
80
181
|
typeof slot === "number" ? (
|
|
81
182
|
<li key={slot}>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
className={cn(
|
|
183
|
+
{control({
|
|
184
|
+
target: slot,
|
|
185
|
+
label: `Page ${slot + 1}`,
|
|
186
|
+
current: slot === current,
|
|
187
|
+
className: cn(
|
|
88
188
|
pageButton,
|
|
89
189
|
slot === current &&
|
|
90
190
|
"border-border bg-card font-semibold text-foreground hover:bg-card",
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
</button>
|
|
191
|
+
),
|
|
192
|
+
children: slot + 1,
|
|
193
|
+
})}
|
|
95
194
|
</li>
|
|
96
195
|
) : (
|
|
97
196
|
<li key={slot} aria-hidden="true" className="flex min-w-8 justify-center text-sm text-muted-foreground">
|
|
@@ -100,16 +199,18 @@ export function Pagination({
|
|
|
100
199
|
),
|
|
101
200
|
)}
|
|
102
201
|
<li>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
disabled
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
202
|
+
{control({
|
|
203
|
+
target: current + 1,
|
|
204
|
+
label: "Next page",
|
|
205
|
+
disabled: current === last,
|
|
206
|
+
className: cn(pageButton, "gap-1 pr-1.5"),
|
|
207
|
+
children: (
|
|
208
|
+
<>
|
|
209
|
+
<span className="hidden sm:inline">Next</span>
|
|
210
|
+
<ChevronRight aria-hidden="true" />
|
|
211
|
+
</>
|
|
212
|
+
),
|
|
213
|
+
})}
|
|
113
214
|
</li>
|
|
114
215
|
</ul>
|
|
115
216
|
</nav>
|
|
@@ -224,3 +224,27 @@ export const WorstCase: Story = {
|
|
|
224
224
|
</BothThemes>
|
|
225
225
|
),
|
|
226
226
|
};
|
|
227
|
+
|
|
228
|
+
const PART = "Adafruit-BME680-Temperature-Humidity-Pressure-Gas-Sensor-Breakout";
|
|
229
|
+
|
|
230
|
+
/** `wrap` keeps the whole label readable: one line matches a fixed tag's height; more lines grow it, with the icon and remove button on the first line. */
|
|
231
|
+
export const Wrap: Story = {
|
|
232
|
+
render: () => (
|
|
233
|
+
<BothThemes>
|
|
234
|
+
<div className="flex w-full max-w-[220px] flex-col items-start gap-2" data-testid="wrap-column">
|
|
235
|
+
{(["sm", "md"] as const).map((size) => (
|
|
236
|
+
<div key={size} className="flex flex-col items-start gap-2">
|
|
237
|
+
<div className="flex items-start gap-2">
|
|
238
|
+
<Tag size={size} data-probe={`fixed-${size}`}>Short</Tag>
|
|
239
|
+
<Tag size={size} wrap data-probe={`wrap-one-${size}`}>Short</Tag>
|
|
240
|
+
<Tag size={size} wrap onRemove={() => {}} data-probe={`wrap-remove-${size}`}>Short</Tag>
|
|
241
|
+
</div>
|
|
242
|
+
<Tag size={size} wrap tone="teal" icon={<FileText />} onRemove={() => {}} data-probe={`wrap-long-${size}`}>
|
|
243
|
+
{PART}
|
|
244
|
+
</Tag>
|
|
245
|
+
</div>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
</BothThemes>
|
|
249
|
+
),
|
|
250
|
+
};
|
package/src/primitives/tag.tsx
CHANGED
|
@@ -33,16 +33,31 @@ export interface TagProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "c
|
|
|
33
33
|
* the label is a string; pass it when the label is not plain text.
|
|
34
34
|
*/
|
|
35
35
|
removeLabel?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Wraps a long label onto more lines instead of truncating it. Use it where
|
|
38
|
+
* the full text matters and no hover is available to read a `title`, such
|
|
39
|
+
* as model-written part names read on a phone or on paper.
|
|
40
|
+
*/
|
|
41
|
+
wrap?: boolean;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
const SIZE = {
|
|
39
|
-
sm: "
|
|
40
|
-
md: "
|
|
45
|
+
sm: "gap-1 px-2 text-xs [&_svg]:size-3",
|
|
46
|
+
md: "gap-1.5 px-2.5 text-sm [&_svg]:size-3.5",
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
// A truncating tag has one fixed height. A wrapping tag carries its vertical
|
|
50
|
+
// padding on the label instead, sized so one line is exactly that height
|
|
51
|
+
// (sm: 16px line + 6 + 2px border = 24; md: 20 + 6 + 2 = 28), and aligns the
|
|
52
|
+
// icon and remove button to the first line rather than the middle.
|
|
53
|
+
const WRAP = {
|
|
54
|
+
sm: { box: "items-start", label: "py-[3px]", icon: "h-[22px] items-center" },
|
|
55
|
+
md: { box: "items-start", label: "py-[3px]", icon: "h-[26px] items-center" },
|
|
41
56
|
} as const;
|
|
42
57
|
|
|
43
58
|
const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
44
59
|
(
|
|
45
|
-
{ className, children, tone = "neutral", emphasis = "soft", size = "sm", icon, onRemove, removeLabel, ...props },
|
|
60
|
+
{ className, children, tone = "neutral", emphasis = "soft", size = "sm", icon, onRemove, removeLabel, wrap = false, ...props },
|
|
46
61
|
ref,
|
|
47
62
|
) => {
|
|
48
63
|
const classes = TONE_CLASSES[tone];
|
|
@@ -51,8 +66,9 @@ const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
|
51
66
|
<span
|
|
52
67
|
ref={ref}
|
|
53
68
|
className={cn(
|
|
54
|
-
"inline-flex min-w-0 max-w-full shrink-0
|
|
69
|
+
"inline-flex min-w-0 max-w-full shrink-0 rounded-md border font-medium",
|
|
55
70
|
SIZE[size],
|
|
71
|
+
wrap ? WRAP[size].box : ["items-center", size === "sm" ? "h-6" : "h-7"],
|
|
56
72
|
emphasis === "outline" ? classes.outline : classes.surface,
|
|
57
73
|
onRemove && (size === "sm" ? "pr-0.5" : "pr-1"),
|
|
58
74
|
className,
|
|
@@ -60,15 +76,20 @@ const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
|
60
76
|
{...props}
|
|
61
77
|
>
|
|
62
78
|
{icon ? (
|
|
63
|
-
<span aria-hidden="true" className={cn("inline-flex shrink-0", classes.icon)}>
|
|
79
|
+
<span aria-hidden="true" className={cn("inline-flex shrink-0", wrap && WRAP[size].icon, classes.icon)}>
|
|
64
80
|
{icon}
|
|
65
81
|
</span>
|
|
66
82
|
) : null}
|
|
67
|
-
{/*
|
|
68
|
-
out
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
{/* By default a long label truncates here rather than pushing the
|
|
84
|
+
remove button out, and the title gives its full text on hover.
|
|
85
|
+
`wrap` breaks it across lines instead, even inside one long token. */}
|
|
86
|
+
{wrap ? (
|
|
87
|
+
<span className={cn("min-w-0 [overflow-wrap:anywhere]", WRAP[size].label)}>{children}</span>
|
|
88
|
+
) : (
|
|
89
|
+
<span className="min-w-0 truncate" title={typeof children === "string" ? children : undefined}>
|
|
90
|
+
{children}
|
|
91
|
+
</span>
|
|
92
|
+
)}
|
|
72
93
|
{onRemove ? (
|
|
73
94
|
<button
|
|
74
95
|
type="button"
|
|
@@ -79,6 +100,8 @@ const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
|
79
100
|
// the hit area without moving the tag's layout.
|
|
80
101
|
"relative inline-flex shrink-0 items-center justify-center rounded-sm opacity-70 transition-[opacity,background-color] duration-150 ease-out hover:bg-[color-mix(in_srgb,currentColor_14%,transparent)] hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']",
|
|
81
102
|
size === "sm" ? "size-5" : "size-6",
|
|
103
|
+
// Centred on the first line box (22px sm, 26px md) when wrapping.
|
|
104
|
+
wrap && "mt-px",
|
|
82
105
|
focusRing,
|
|
83
106
|
)}
|
|
84
107
|
>
|