@terpjs/react-core 0.8.0 → 0.10.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/README.md +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +3 -0
- package/src/markers.test.ts +141 -15
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +192 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +71 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
package/src/ui/Button.test.tsx
CHANGED
|
@@ -38,10 +38,78 @@ describe("Button", () => {
|
|
|
38
38
|
expect(button.textContent).toBe("iDo it");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
it("names a non-default size and leaves the standard control unmarked", () => {
|
|
42
|
+
// md is the absence of an attribute, because the standard control's geometry IS the base
|
|
43
|
+
// rule — the shape density already uses, where "comfortable" matches no rule either. So
|
|
44
|
+
// this asserts the absence as deliberately as it asserts the presence: stamping
|
|
45
|
+
// data-size="md" would be harmless but would make the sheet's two-rule split read as an
|
|
46
|
+
// omission.
|
|
47
|
+
const { rerender } = render(<Button size="sm">Small</Button>);
|
|
48
|
+
expect(screen.getByRole("button", { name: "Small" })).toHaveAttribute("data-size", "sm");
|
|
49
|
+
rerender(<Button size="lg">Large</Button>);
|
|
50
|
+
expect(screen.getByRole("button", { name: "Large" })).toHaveAttribute("data-size", "lg");
|
|
51
|
+
rerender(<Button>Standard</Button>);
|
|
52
|
+
const standard = screen.getByRole("button", { name: "Standard" });
|
|
53
|
+
expect(standard.hasAttribute("data-size")).toBe(false);
|
|
54
|
+
expect(standard.getAttribute("style")).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("fills its container from an attribute rather than an inline width", () => {
|
|
58
|
+
// The point of the prop: `style={{ width: "100%" }}` is the only other way there, and app
|
|
59
|
+
// modules may not write it (ADR 0059) — so full width was a shape the framework could
|
|
60
|
+
// produce and its consumers could not ask for. The absence of a style attribute is the
|
|
61
|
+
// half that matters, because an inline width would outrank the app theme.css that ADR 0094
|
|
62
|
+
// exists to empower.
|
|
63
|
+
render(<Button fullWidth>Wide</Button>);
|
|
64
|
+
const button = screen.getByRole("button", { name: "Wide" });
|
|
65
|
+
expect(button).toHaveAttribute("data-full-width", "true");
|
|
66
|
+
expect(button.getAttribute("style")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("marks a loading button busy, disables it, and shows the spinner in the icon slot", () => {
|
|
70
|
+
render(
|
|
71
|
+
<Button loading icon={<span data-testid="ico">i</span>}>
|
|
72
|
+
Saving
|
|
73
|
+
</Button>,
|
|
74
|
+
);
|
|
75
|
+
const button = screen.getByRole("button", { name: "Saving" });
|
|
76
|
+
expect(button).toHaveAttribute("data-loading", "true");
|
|
77
|
+
expect(button).toHaveAttribute("aria-busy", "true");
|
|
78
|
+
// Disabled, so a second click cannot start the same request twice — the reason the state
|
|
79
|
+
// exists at all rather than being decoration on a still-live control.
|
|
80
|
+
expect(button).toBeDisabled();
|
|
81
|
+
// The spinner REPLACES the icon rather than joining it, so the button's width does not
|
|
82
|
+
// jump as it enters and leaves the state.
|
|
83
|
+
expect(screen.queryByTestId("ico")).toBeNull();
|
|
84
|
+
expect(button.querySelector('[data-terp="spinner-ring"]')).not.toBeNull();
|
|
85
|
+
expect(button.getAttribute("style")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("keeps a loading button disabled even when the caller says otherwise", () => {
|
|
89
|
+
// `disabled={false}` and `loading` together is a real combination — a form that computes
|
|
90
|
+
// one from validity and the other from the request in flight — and the request has to
|
|
91
|
+
// win, or the state is advisory.
|
|
92
|
+
render(
|
|
93
|
+
<Button loading disabled={false}>
|
|
94
|
+
Saving
|
|
95
|
+
</Button>,
|
|
96
|
+
);
|
|
97
|
+
expect(screen.getByRole("button", { name: "Saving" })).toBeDisabled();
|
|
98
|
+
});
|
|
99
|
+
|
|
41
100
|
it("still forwards an explicit style, so framework callers keep their escape", () => {
|
|
42
|
-
// The sheet owns the base
|
|
43
|
-
//
|
|
44
|
-
//
|
|
101
|
+
// The sheet owns the base, and the escape still has to work: a caller may pass a measured
|
|
102
|
+
// value the sheet has no business owning, and it wins because a style attribute outranks
|
|
103
|
+
// any author rule (ADR 0094 §3).
|
|
104
|
+
//
|
|
105
|
+
// This comment has now cited three different homes for the same 100%, which is the useful
|
|
106
|
+
// part of it. First it was LoginView's inline style. Then a rule on the login form's group,
|
|
107
|
+
// because a fixed 100% is layout policy rather than a measured value and the sheet could
|
|
108
|
+
// reach it — with a prop declined as API the package did not need for one internal caller.
|
|
109
|
+
// Now it is `fullWidth`, because the constraint that mattered was never the internal caller:
|
|
110
|
+
// app modules may not write `style` at all, so full width was a shape the framework could
|
|
111
|
+
// produce and its consumers could not ask for. The group rule is gone and LoginView passes
|
|
112
|
+
// the prop. The escape below is unaffected either way, which is why it is still here.
|
|
45
113
|
render(<Button style={{ width: "100%" }}>Wide</Button>);
|
|
46
114
|
expect(screen.getByRole("button", { name: "Wide" }).style.width).toBe("100%");
|
|
47
115
|
});
|
package/src/ui/Button.tsx
CHANGED
|
@@ -1,13 +1,39 @@
|
|
|
1
1
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
2
2
|
|
|
3
|
+
import { InlineSpinner } from "../LoadingState";
|
|
3
4
|
import { injectTerpStyles } from "../styles";
|
|
4
5
|
|
|
5
6
|
injectTerpStyles();
|
|
6
7
|
|
|
7
8
|
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
|
8
9
|
|
|
10
|
+
/** Control size: `"md"` is the standard control and needs no attribute (see below). */
|
|
11
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
12
|
+
|
|
9
13
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
10
14
|
variant?: ButtonVariant;
|
|
15
|
+
/**
|
|
16
|
+
* Control size (default `"md"`). Changes the height, the horizontal padding and the label
|
|
17
|
+
* size together; composes with `data-density`, so a `"sm"` button inside a compact subtree
|
|
18
|
+
* is shorter still.
|
|
19
|
+
*/
|
|
20
|
+
size?: ButtonSize;
|
|
21
|
+
/**
|
|
22
|
+
* The action is in flight: shows a spinner in the icon slot, marks the control
|
|
23
|
+
* `aria-busy` and disables it, so a second click cannot start the request twice.
|
|
24
|
+
*
|
|
25
|
+
* The spinner replaces `icon` rather than joining it, which keeps the button's width from
|
|
26
|
+
* jumping as it enters and leaves the state.
|
|
27
|
+
*/
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Fill the container's inline size instead of the label's.
|
|
31
|
+
*
|
|
32
|
+
* It exists because the alternative was `style={{ width: "100%" }}`, which app modules may
|
|
33
|
+
* not write (ADR 0059) — so full width was a shape the framework could produce and its
|
|
34
|
+
* consumers could not ask for.
|
|
35
|
+
*/
|
|
36
|
+
fullWidth?: boolean;
|
|
11
37
|
/** Optional leading icon, rendered before `children` (e.g. `<Icon name="plus" />`). */
|
|
12
38
|
icon?: ReactNode;
|
|
13
39
|
}
|
|
@@ -20,23 +46,50 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
20
46
|
* matched on the `data-terp` / `data-variant` attributes set below (ADR 0094). So the
|
|
21
47
|
* variant is a fact about the element rather than a style object chosen in here — which
|
|
22
48
|
* is what a test should assert, and what an app's `theme.css` can restyle.
|
|
49
|
+
*
|
|
50
|
+
* `size`, `loading` and `fullWidth` are all closed sets, so all three are attributes with a
|
|
51
|
+
* rule each (ADR 0094 §3). Two details about how they are stamped are deliberate:
|
|
52
|
+
*
|
|
53
|
+
* `data-size` appears only for `"sm"` and `"lg"`. The standard control's geometry IS the base
|
|
54
|
+
* rule, so `md` is the absence of an attribute — the same shape density takes, where
|
|
55
|
+
* "comfortable" is the token sheet's `:root` value and the attribute for it matches no rule.
|
|
56
|
+
* (`data-variant` is stamped even for its default because `primary` has a rule of its own;
|
|
57
|
+
* the two idioms differ for that reason rather than by accident.)
|
|
58
|
+
*
|
|
59
|
+
* `loading` sets `disabled` as well, and the sizes are expressed as a `calc()` off the
|
|
60
|
+
* density token rather than as heights of their own, so density keeps composing with all
|
|
61
|
+
* three sizes without a second family of tokens to keep in step.
|
|
23
62
|
*/
|
|
24
63
|
export function Button({
|
|
25
64
|
variant = "primary",
|
|
65
|
+
size = "md",
|
|
66
|
+
loading = false,
|
|
67
|
+
fullWidth = false,
|
|
26
68
|
icon,
|
|
27
69
|
type = "button",
|
|
70
|
+
disabled,
|
|
28
71
|
children,
|
|
29
72
|
...rest
|
|
30
73
|
}: ButtonProps) {
|
|
74
|
+
const leading = loading ? <InlineSpinner size={14} /> : icon;
|
|
31
75
|
return (
|
|
32
|
-
<button
|
|
33
|
-
{
|
|
76
|
+
<button
|
|
77
|
+
type={type}
|
|
78
|
+
data-terp="button"
|
|
79
|
+
data-variant={variant}
|
|
80
|
+
data-size={size === "md" ? undefined : size}
|
|
81
|
+
data-loading={loading ? "true" : undefined}
|
|
82
|
+
data-full-width={fullWidth ? "true" : undefined}
|
|
83
|
+
disabled={disabled === true || loading}
|
|
84
|
+
aria-busy={loading ? true : undefined}
|
|
85
|
+
{...rest}
|
|
86
|
+
>
|
|
87
|
+
{leading !== undefined && leading !== null && (
|
|
34
88
|
<span aria-hidden="true" data-terp="button-icon">
|
|
35
|
-
{
|
|
89
|
+
{leading}
|
|
36
90
|
</span>
|
|
37
91
|
)}
|
|
38
92
|
{children}
|
|
39
93
|
</button>
|
|
40
94
|
);
|
|
41
95
|
}
|
|
42
|
-
|
package/src/ui/Card.test.tsx
CHANGED
|
@@ -41,4 +41,17 @@ describe("Card", () => {
|
|
|
41
41
|
const card = screen.getByText("alleen inhoud").closest('[data-terp="card"]');
|
|
42
42
|
expect(card?.querySelector('[data-terp="card-header"]')).toBeNull();
|
|
43
43
|
});
|
|
44
|
+
it("names the plain variant and leaves the boxed default unmarked", () => {
|
|
45
|
+
// The chrome-less titled region. A variant rather than a `Section` component of its own,
|
|
46
|
+
// because a chrome-less region is this element with three declarations removed — a second
|
|
47
|
+
// component would have meant six more markers describing the same DOM, and a `Surface` is
|
|
48
|
+
// a Card with no title, which this already is.
|
|
49
|
+
const { rerender } = render(<Card variant="plain" title="Plain">body</Card>);
|
|
50
|
+
const plain = screen.getByRole("heading", { name: "Plain" }).closest("section");
|
|
51
|
+
expect(plain).toHaveAttribute("data-variant", "plain");
|
|
52
|
+
rerender(<Card title="Boxed">body</Card>);
|
|
53
|
+
const boxed = screen.getByRole("heading", { name: "Boxed" }).closest("section");
|
|
54
|
+
expect(boxed!.hasAttribute("data-variant")).toBe(false);
|
|
55
|
+
expect(boxed!.getAttribute("style")).toBeNull();
|
|
56
|
+
});
|
|
44
57
|
});
|
package/src/ui/Card.tsx
CHANGED
|
@@ -7,8 +7,27 @@ import type { UiText } from "../uiText";
|
|
|
7
7
|
|
|
8
8
|
injectTerpStyles();
|
|
9
9
|
|
|
10
|
+
/** Chrome, or none: `"plain"` keeps the heading and drops the box. */
|
|
11
|
+
export type CardVariant = "boxed" | "plain";
|
|
12
|
+
|
|
10
13
|
export interface CardProps
|
|
11
14
|
extends Omit<HTMLAttributes<HTMLElement>, "style" | "title"> {
|
|
15
|
+
/**
|
|
16
|
+
* Whether the block carries a box (default `"boxed"`) or just its heading (`"plain"`).
|
|
17
|
+
*
|
|
18
|
+
* `"plain"` is the labelled-region case: a titled group inside something that is already a
|
|
19
|
+
* surface, where a second border reads as a frame around a frame. The commonest instance is
|
|
20
|
+
* a section whose body is a `DataView` — boxed, the table gets a border inside a border and
|
|
21
|
+
* loses its full width.
|
|
22
|
+
*
|
|
23
|
+
* It is a variant rather than a `Section` component of its own, and that is a decision worth
|
|
24
|
+
* knowing. A chrome-less titled region is exactly this element with three declarations
|
|
25
|
+
* removed: `Card` already renders a `<section>` with an `<h3>` and stacks its children on
|
|
26
|
+
* the token scale. A second component would have meant six more markers describing the same
|
|
27
|
+
* DOM, and a `Surface` — the third name the diagnosis suggested — is a `Card` with no title,
|
|
28
|
+
* which this already is. Two names for one box is the `LoadingButton` mistake.
|
|
29
|
+
*/
|
|
30
|
+
variant?: CardVariant;
|
|
12
31
|
/** Optional section heading, rendered as an `<h3>` in the card's header row. */
|
|
13
32
|
title?: UiText;
|
|
14
33
|
/** Optional muted one-liner under the title (what this block is about). */
|
|
@@ -31,6 +50,7 @@ export interface CardProps
|
|
|
31
50
|
* spacing scale.
|
|
32
51
|
*/
|
|
33
52
|
export function Card({
|
|
53
|
+
variant = "boxed",
|
|
34
54
|
title,
|
|
35
55
|
description,
|
|
36
56
|
actions,
|
|
@@ -42,7 +62,14 @@ export function Card({
|
|
|
42
62
|
const resolve = useUiText();
|
|
43
63
|
const hasHeader = title !== undefined || actions !== undefined;
|
|
44
64
|
return (
|
|
45
|
-
<Component
|
|
65
|
+
<Component
|
|
66
|
+
{...rest}
|
|
67
|
+
data-terp="card"
|
|
68
|
+
// `boxed` is the base rule, so its attribute would describe the default twice — the
|
|
69
|
+
// idiom density, Button's `md` and Grid's `auto` all use.
|
|
70
|
+
data-variant={variant === "boxed" ? undefined : variant}
|
|
71
|
+
data-gap={String(gap)}
|
|
72
|
+
>
|
|
46
73
|
{hasHeader ? (
|
|
47
74
|
<div data-terp="card-header">
|
|
48
75
|
<div data-terp="card-heading">
|
package/src/ui/Checkbox.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { InputHTMLAttributes } from "react";
|
|
1
|
+
import type { ChangeEvent, InputHTMLAttributes } from "react";
|
|
2
2
|
|
|
3
3
|
import { injectTerpStyles } from "../styles";
|
|
4
4
|
import { useUiText } from "../uiText";
|
|
@@ -25,7 +25,15 @@ export function Checkbox({ label, checked, defaultChecked, onChange, style, ...r
|
|
|
25
25
|
data-terp="checkbox"
|
|
26
26
|
checked={checked}
|
|
27
27
|
defaultChecked={defaultChecked}
|
|
28
|
-
|
|
28
|
+
// Attached only when there is something to call, exactly as Select does and for the
|
|
29
|
+
// reason stated there: an unconditional handler silences React's own "you provided a
|
|
30
|
+
// `checked` prop to a form field without an `onChange` handler" guard, so a caller who
|
|
31
|
+
// pinned `checked` and forgot the handler gets a control that looks operable, never
|
|
32
|
+
// changes, and says nothing about it. The spread form is what keeps the prop absent
|
|
33
|
+
// rather than present-and-undefined, which React treats as the same mistake.
|
|
34
|
+
{...(onChange !== undefined
|
|
35
|
+
? { onChange: (event: ChangeEvent<HTMLInputElement>) => onChange(event.currentTarget.checked) }
|
|
36
|
+
: {})}
|
|
29
37
|
/>
|
|
30
38
|
<span>{resolve(label)}</span>
|
|
31
39
|
</label>
|
package/src/ui/Combobox.test.tsx
CHANGED
|
@@ -31,6 +31,55 @@ describe("Combobox", () => {
|
|
|
31
31
|
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
it("matches a needle that the host's own case fold would have hidden", () => {
|
|
35
|
+
// The filter used to fold with `toLocaleLowerCase`, which asks the host what lowercase means.
|
|
36
|
+
// Turkish has two i's, and folded there `Italy` becomes `ıtaly` — dotless — which does not
|
|
37
|
+
// contain the `i` the user typed. Every option with a capital I disappeared for a Turkish
|
|
38
|
+
// visitor and for nobody else, and folding BOTH sides the same way does not help: the needle
|
|
39
|
+
// comes from a keyboard and the haystack from a server.
|
|
40
|
+
//
|
|
41
|
+
// The host is simulated rather than assumed, because the suite runs on nl-NL and a test that
|
|
42
|
+
// merely typed `i` would pass under the bug here and fail only in Istanbul. The prototype
|
|
43
|
+
// patch is scoped to this test and restored in `finally`.
|
|
44
|
+
const original = String.prototype.toLocaleLowerCase;
|
|
45
|
+
// Confirm the hazard is real on this ICU build before relying on it as the mechanism.
|
|
46
|
+
expect(original.call("Italy", "tr")).not.toContain("i");
|
|
47
|
+
String.prototype.toLocaleLowerCase = function turkish(this: string) {
|
|
48
|
+
return original.call(this, "tr");
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
render(
|
|
52
|
+
<Combobox
|
|
53
|
+
aria-label="Country"
|
|
54
|
+
options={[...options, { value: "it", label: "Italy" }]}
|
|
55
|
+
onChange={vi.fn()}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
const input = screen.getByRole("combobox", { name: /Country/ });
|
|
59
|
+
fireEvent.focus(input);
|
|
60
|
+
// BOTH cases, because each catches a different half and neither catches the other.
|
|
61
|
+
//
|
|
62
|
+
// Lowercase is the real defect: the user types the dotted `i` their keyboard produces,
|
|
63
|
+
// Turkish folds the label to the dotless `ıtaly`, and the option vanishes. The ORIGINAL
|
|
64
|
+
// code folded both sides with the host locale, which is self-consistent — "ıtal" does
|
|
65
|
+
// occur in "ıtaly" — so a capital-I needle passes under the very bug this test exists
|
|
66
|
+
// for. The first version of this test asserted only the capital, and was green against
|
|
67
|
+
// it.
|
|
68
|
+
//
|
|
69
|
+
// Capital is still needed: it is the only case that fails when the NEEDLE alone reverts
|
|
70
|
+
// to the host fold, which lowercase cannot see because folding "ital" changes nothing.
|
|
71
|
+
for (const needle of ["ital", "Ital"]) {
|
|
72
|
+
fireEvent.change(input, { target: { value: needle } });
|
|
73
|
+
expect(
|
|
74
|
+
screen.getByRole("option", { name: "Italy" }),
|
|
75
|
+
`typing ${needle} must still match Italy on a Turkish host`,
|
|
76
|
+
).toBeInTheDocument();
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
String.prototype.toLocaleLowerCase = original;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
34
83
|
it("supports controlled value, Field labels, ARIA active option and keyboard navigation", () => {
|
|
35
84
|
const onChange = vi.fn();
|
|
36
85
|
render(
|
package/src/ui/Combobox.tsx
CHANGED
|
@@ -72,11 +72,17 @@ export function Combobox({
|
|
|
72
72
|
const isOpen = open && disabled !== true;
|
|
73
73
|
|
|
74
74
|
const renderedOptions = useMemo(() => {
|
|
75
|
-
|
|
75
|
+
// `toLowerCase`, not the locale-aware fold: this decides whether a substring MATCHES, and a
|
|
76
|
+
// match is not a presentation question for the host to answer. Folded against a Turkish host,
|
|
77
|
+
// `Item` becomes `ıtem` — dotless — which does not contain the `i` the user typed, so every
|
|
78
|
+
// option with a capital I disappears for a Turkish visitor and for nobody else.
|
|
79
|
+
// Folding both sides with the same host locale does not rescue it: the needle comes from a
|
|
80
|
+
// keyboard and the haystack from a server, and the two agree only when the fold is invariant.
|
|
81
|
+
const normalized = query.trim().toLowerCase();
|
|
76
82
|
if (normalized.length === 0 || selectedOption !== null && query === resolve(selectedOption.label)) {
|
|
77
83
|
return options;
|
|
78
84
|
}
|
|
79
|
-
return options.filter((option) => resolve(option.label).
|
|
85
|
+
return options.filter((option) => resolve(option.label).toLowerCase().includes(normalized));
|
|
80
86
|
}, [options, query, resolve, selectedOption]);
|
|
81
87
|
const enabledOptions = renderedOptions.filter((option) => !option.disabled);
|
|
82
88
|
const activeOption = renderedOptions.find((option) => option.value === activeValue) ?? enabledOptions[0] ?? null;
|
package/src/ui/DatePicker.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { KeyboardEvent } from "react";
|
|
3
3
|
|
|
4
|
+
import { formatDate } from "../format";
|
|
4
5
|
import { useLocale } from "../locale";
|
|
5
6
|
import { injectTerpStyles } from "../styles";
|
|
6
7
|
import { useUiText } from "../uiText";
|
|
@@ -414,13 +415,34 @@ function useDateLocale() {
|
|
|
414
415
|
return useLocale()?.locale;
|
|
415
416
|
}
|
|
416
417
|
|
|
417
|
-
|
|
418
|
-
|
|
418
|
+
// `formatDate` used to live here, three functions deep in a file about calendars, and it was the
|
|
419
|
+
// only general-purpose locale-correct date rendering in the package. It is `../format` now and
|
|
420
|
+
// imported back; the three below stay, because a spoken day, a grid caption and a column header
|
|
421
|
+
// are calendar parts rather than general formatting.
|
|
422
|
+
//
|
|
423
|
+
// They are cached for the reason `../format` caches its own: constructing an Intl formatter costs
|
|
424
|
+
// far more than using one — measured at roughly 55x on this repository's Node — and the spoken-day
|
|
425
|
+
// formatter runs once per DAY CELL, so an open calendar built 42 of them per render and a further
|
|
426
|
+
// seven for the column headers.
|
|
427
|
+
const CALENDAR_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
|
|
428
|
+
|
|
429
|
+
function calendarFormatter(
|
|
430
|
+
locale: string | undefined,
|
|
431
|
+
kind: "full" | "month" | "weekday",
|
|
432
|
+
options: Intl.DateTimeFormatOptions,
|
|
433
|
+
): Intl.DateTimeFormat {
|
|
434
|
+
const key = `${kind}|${locale ?? ""}`;
|
|
435
|
+
let formatter = CALENDAR_FORMATTERS.get(key);
|
|
436
|
+
if (formatter === undefined) {
|
|
437
|
+
formatter = new Intl.DateTimeFormat(locale, options);
|
|
438
|
+
CALENDAR_FORMATTERS.set(key, formatter);
|
|
439
|
+
}
|
|
440
|
+
return formatter;
|
|
419
441
|
}
|
|
420
442
|
|
|
421
443
|
/** The whole date, spoken: what a day cell announces, since its text is only a number. */
|
|
422
444
|
function formatFullDate(date: Date, locale: string | undefined) {
|
|
423
|
-
return
|
|
445
|
+
return calendarFormatter(locale, "full", {
|
|
424
446
|
weekday: "long",
|
|
425
447
|
day: "numeric",
|
|
426
448
|
month: "long",
|
|
@@ -429,12 +451,13 @@ function formatFullDate(date: Date, locale: string | undefined) {
|
|
|
429
451
|
}
|
|
430
452
|
|
|
431
453
|
function formatMonth(date: Date, locale: string | undefined) {
|
|
432
|
-
return
|
|
454
|
+
return calendarFormatter(locale, "month", { year: "numeric", month: "long" }).format(date);
|
|
433
455
|
}
|
|
434
456
|
|
|
435
457
|
function weekdayNames(locale: string | undefined) {
|
|
436
458
|
const base = new Date(2024, 0, 7);
|
|
437
|
-
|
|
459
|
+
const formatter = calendarFormatter(locale, "weekday", { weekday: "short" });
|
|
460
|
+
return Array.from({ length: 7 }, (_, index) => formatter.format(addDays(base, index)));
|
|
438
461
|
}
|
|
439
462
|
|
|
440
463
|
function normalizeRange(value: DateRangeValue): DateRangeValue {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Field } from "../Field";
|
|
6
|
+
import { Input } from "./Input";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
describe("Input", () => {
|
|
11
|
+
it("renders a bare input for every type but password", () => {
|
|
12
|
+
// The wrapper exists for exactly one type. Two child selectors in the sheet reach for
|
|
13
|
+
// `data-terp="input"` as a DIRECT child — the toolbar search and the resource-list create
|
|
14
|
+
// field — and both would break against a wrapper, so "only password wraps" is a fact those
|
|
15
|
+
// rules depend on rather than an implementation detail.
|
|
16
|
+
const { container, rerender } = render(<Input type="text" defaultValue="" />);
|
|
17
|
+
expect(container.firstElementChild?.tagName).toBe("INPUT");
|
|
18
|
+
rerender(<Input type="search" defaultValue="" />);
|
|
19
|
+
expect(container.firstElementChild?.tagName).toBe("INPUT");
|
|
20
|
+
rerender(<Input defaultValue="" />);
|
|
21
|
+
expect(container.firstElementChild?.tagName).toBe("INPUT");
|
|
22
|
+
rerender(<Input type="password" defaultValue="" />);
|
|
23
|
+
expect(container.firstElementChild?.getAttribute("data-terp")).toBe("input-password");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("reveals and re-hides the value, and its NAME is what says which it will do", () => {
|
|
27
|
+
// One encoding of the state, not two. The name swaps, and there is deliberately no
|
|
28
|
+
// `aria-pressed`: a toggle announced as "Hide password, pressed" claims the value is hidden
|
|
29
|
+
// and shown at once. It also keeps this button outside the sheet's shared hover guard, which
|
|
30
|
+
// excludes `[aria-pressed="true"]` and would leave the revealed toggle with no hover at all.
|
|
31
|
+
render(<Input type="password" aria-label="Password" defaultValue="hunter2" />);
|
|
32
|
+
const input = screen.getByLabelText("Password");
|
|
33
|
+
expect(input).toHaveAttribute("type", "password");
|
|
34
|
+
const toggle = screen.getByRole("button", { name: "Show password" });
|
|
35
|
+
expect(toggle).not.toHaveAttribute("aria-pressed");
|
|
36
|
+
fireEvent.click(toggle);
|
|
37
|
+
expect(input).toHaveAttribute("type", "text");
|
|
38
|
+
const now = screen.getByRole("button", { name: "Hide password" });
|
|
39
|
+
expect(now).not.toHaveAttribute("aria-pressed");
|
|
40
|
+
fireEvent.click(now);
|
|
41
|
+
expect(input).toHaveAttribute("type", "password");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does not submit the form it sits in", () => {
|
|
45
|
+
// A button inside a form defaults to type="submit". A reveal toggle that posted the form
|
|
46
|
+
// would be a data-loss bug reachable by one click, and no visual lane could see it.
|
|
47
|
+
render(<Input type="password" aria-label="Password" defaultValue="" />);
|
|
48
|
+
expect(screen.getByRole("button", { name: "Show password" })).toHaveAttribute("type", "button");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps Field's aria on the input, not on the wrapper", () => {
|
|
52
|
+
// The one that would have shipped silently. `Field` clones its control to inject
|
|
53
|
+
// `aria-describedby` and `aria-invalid`, and the sheet's invalid border is
|
|
54
|
+
// `input[data-terp="input"][aria-invalid="true"]` — a single-element selector. If the spread
|
|
55
|
+
// landed on the wrapper span, the attribute would sit on one element and the marker on
|
|
56
|
+
// another, the selector would match neither, and every password field with a hint or an error
|
|
57
|
+
// would quietly lose its red border. There is one such field in the example app today.
|
|
58
|
+
render(
|
|
59
|
+
<Field label="Password" hint="At least 16 characters" error="Too short">
|
|
60
|
+
<Input type="password" defaultValue="" />
|
|
61
|
+
</Field>,
|
|
62
|
+
);
|
|
63
|
+
const input = screen.getByLabelText("Password");
|
|
64
|
+
expect(input.tagName).toBe("INPUT");
|
|
65
|
+
expect(input).toHaveAttribute("aria-invalid", "true");
|
|
66
|
+
expect(input).toHaveAttribute("data-terp", "input");
|
|
67
|
+
const described = (input.getAttribute("aria-describedby") ?? "").split(" ").filter(Boolean);
|
|
68
|
+
expect(described).toHaveLength(2);
|
|
69
|
+
// And the wrapper carries none of it.
|
|
70
|
+
const wrapper = input.parentElement!;
|
|
71
|
+
expect(wrapper).toHaveAttribute("data-terp", "input-password");
|
|
72
|
+
expect(wrapper).not.toHaveAttribute("aria-invalid");
|
|
73
|
+
expect(wrapper).not.toHaveAttribute("aria-describedby");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("is named by the label TEXT, so the toggle's own name cannot join it", () => {
|
|
77
|
+
// `Field` wraps the control in a `<label>`, so the toggle is a label descendant, and a label
|
|
78
|
+
// takes its name from everything inside it. Chromium duly computed "Password Show password"
|
|
79
|
+
// for this input until `Field` started pointing `aria-labelledby` at the label's text span.
|
|
80
|
+
//
|
|
81
|
+
// WHAT THIS TEST CAN AND CANNOT SEE, because the first version of it was worthless: jsdom's
|
|
82
|
+
// accessible-name implementation does NOT walk into a descendant's `aria-label`, so
|
|
83
|
+
// `toHaveAccessibleName("Password")` passed here while the real browser disagreed. It asserted
|
|
84
|
+
// the deviation, not the fix. So the wiring is what is pinned here — the attribute exists and
|
|
85
|
+
// resolves to the label text — and the computed NAME is asserted in a real engine, in
|
|
86
|
+
// apps/workbench/visual/computed.spec.ts against the admin-user-create specimen.
|
|
87
|
+
render(
|
|
88
|
+
<Field label="Password">
|
|
89
|
+
<Input type="password" defaultValue="" />
|
|
90
|
+
</Field>,
|
|
91
|
+
);
|
|
92
|
+
const input = screen.getByLabelText("Password");
|
|
93
|
+
expect(input.tagName).toBe("INPUT");
|
|
94
|
+
const labelledBy = input.getAttribute("aria-labelledby");
|
|
95
|
+
expect(labelledBy).not.toBeNull();
|
|
96
|
+
expect(document.getElementById(labelledBy!)?.textContent).toBe("Password");
|
|
97
|
+
// The toggle keeps its own name; asserting it were unlabelled would be the wrong fix.
|
|
98
|
+
expect(screen.getByRole("button", { name: "Show password" })).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("disables its toggle when the field itself is disabled or read-only", () => {
|
|
102
|
+
// A field the caller switched off is switched off as a whole. Otherwise the value stays
|
|
103
|
+
// unreachable while the control beside it still reveals it.
|
|
104
|
+
const { rerender } = render(<Input type="password" aria-label="Password" disabled />);
|
|
105
|
+
expect(screen.getByRole("button", { name: "Show password" })).toBeDisabled();
|
|
106
|
+
rerender(<Input type="password" aria-label="Password" readOnly />);
|
|
107
|
+
expect(screen.getByRole("button", { name: "Show password" })).toBeDisabled();
|
|
108
|
+
rerender(<Input type="password" aria-label="Password" />);
|
|
109
|
+
expect(screen.getByRole("button", { name: "Show password" })).toBeEnabled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("keeps a revealed password out of spellcheck, autocorrect and autocapitalisation", () => {
|
|
113
|
+
// Revealing swaps the type to `text`, which in some engines makes the value a candidate for
|
|
114
|
+
// all three — two of which would rewrite what the user typed.
|
|
115
|
+
render(<Input type="password" aria-label="Password" defaultValue="" />);
|
|
116
|
+
const input = screen.getByLabelText("Password");
|
|
117
|
+
fireEvent.click(screen.getByRole("button", { name: "Show password" }));
|
|
118
|
+
expect(input).toHaveAttribute("type", "text");
|
|
119
|
+
expect(input).toHaveAttribute("spellcheck", "false");
|
|
120
|
+
expect(input).toHaveAttribute("autocorrect", "off");
|
|
121
|
+
expect(input).toHaveAttribute("autocapitalize", "none");
|
|
122
|
+
});
|
|
123
|
+
});
|
package/src/ui/Input.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import type { InputHTMLAttributes } from "react";
|
|
2
3
|
|
|
4
|
+
import { Icon } from "../icons";
|
|
3
5
|
import { injectTerpStyles } from "../styles";
|
|
6
|
+
import { useStrings } from "../uiText";
|
|
4
7
|
|
|
5
8
|
injectTerpStyles();
|
|
6
9
|
|
|
@@ -11,7 +14,67 @@ export type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
|
|
11
14
|
* The `data-terp="input"` marker is the whole styling hook: it carries the shared control
|
|
12
15
|
* surface, the focus ring, the hover border and the disabled treatment from the injected
|
|
13
16
|
* sheet, with the element type deciding the geometry (ADR 0094).
|
|
17
|
+
*
|
|
18
|
+
* `type="password"` additionally grows a reveal toggle, and it is the TYPE that decides rather
|
|
19
|
+
* than a prop, for the reason the sheet already gives about `input` and `textarea`: "only their
|
|
20
|
+
* geometry differs, so the element type carries that — no second attribute for a distinction the
|
|
21
|
+
* tag name already makes." A `PasswordInput` export would be the `LoadingButton` mistake, a second
|
|
22
|
+
* name for one `<input>`.
|
|
23
|
+
*
|
|
24
|
+
* An app cannot build this itself, which is why it belongs here rather than in a recipe. The toggle
|
|
25
|
+
* needs a positioned wrapper, and `BOUNDARY_SPEC` refuses both `style` and `className` in module
|
|
26
|
+
* files — exactly the `Button.fullWidth` case: a shape the framework can produce and its consumers
|
|
27
|
+
* cannot ask for.
|
|
14
28
|
*/
|
|
15
|
-
export function Input(
|
|
16
|
-
|
|
29
|
+
export function Input({ type, ...rest }: InputProps) {
|
|
30
|
+
const strings = useStrings();
|
|
31
|
+
const [revealed, setRevealed] = useState(false);
|
|
32
|
+
|
|
33
|
+
if (type !== "password") {
|
|
34
|
+
return <input data-terp="input" type={type} {...rest} />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A field the caller has switched off is switched off as a whole. Without this the value stays
|
|
38
|
+
// hidden and unreachable while the control beside it still reveals it — a disabled field with a
|
|
39
|
+
// working button in it.
|
|
40
|
+
const inert = rest.disabled === true || rest.readOnly === true;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<span data-terp="input-password">
|
|
44
|
+
{/*
|
|
45
|
+
`rest` spreads onto the INPUT, never onto the wrapper, and that is load-bearing rather than
|
|
46
|
+
tidy. `Field` clones its control to inject `aria-describedby` and `aria-invalid`, and the
|
|
47
|
+
sheet's invalid border is `input[data-terp="input"][aria-invalid="true"]` — a single-element
|
|
48
|
+
selector. Land those on the span and the attribute matches nothing while the marker sits on
|
|
49
|
+
a different element, so the red border silently disappears for every password field with a
|
|
50
|
+
hint or an error. There is one live today.
|
|
51
|
+
*/}
|
|
52
|
+
<input
|
|
53
|
+
data-terp="input"
|
|
54
|
+
type={revealed ? "text" : "password"}
|
|
55
|
+
// Revealing swaps the type to `text`, and a text input is a candidate for spellcheck,
|
|
56
|
+
// autocorrect and autocapitalisation in engines that apply them — none of which a password
|
|
57
|
+
// wants, and two of which would silently rewrite what the user typed on a phone. Declared
|
|
58
|
+
// before the spread so a caller can still override them.
|
|
59
|
+
spellCheck={false}
|
|
60
|
+
autoCorrect="off"
|
|
61
|
+
autoCapitalize="none"
|
|
62
|
+
{...rest}
|
|
63
|
+
/>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
data-terp="iconbutton"
|
|
67
|
+
disabled={inert}
|
|
68
|
+
// No `aria-pressed`. The name already carries the state — it swaps between "Show password"
|
|
69
|
+
// and "Hide password" — and encoding it twice makes the two disagree: a toggle announced
|
|
70
|
+
// as "Hide password, pressed" claims the value is hidden and shown at once. It also keeps
|
|
71
|
+
// this button out of the shared hover guard, which excludes `[aria-pressed="true"]` and
|
|
72
|
+
// would otherwise leave the revealed toggle with no hover feedback at all.
|
|
73
|
+
aria-label={revealed ? strings.hidePassword : strings.showPassword}
|
|
74
|
+
onClick={() => setRevealed((on) => !on)}
|
|
75
|
+
>
|
|
76
|
+
<Icon name={revealed ? "eye-off" : "eye"} />
|
|
77
|
+
</button>
|
|
78
|
+
</span>
|
|
79
|
+
);
|
|
17
80
|
}
|