@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.
Files changed (106) hide show
  1. package/README.md +62 -22
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.tsx +35 -75
  11. package/src/ModuleNav.test.tsx +26 -0
  12. package/src/ModuleNav.tsx +45 -38
  13. package/src/Page.test.tsx +9 -6
  14. package/src/Page.tsx +37 -39
  15. package/src/ProfileView.test.tsx +15 -0
  16. package/src/ProfileView.tsx +9 -36
  17. package/src/ResourceList.tsx +13 -24
  18. package/src/SettingsPage.tsx +50 -0
  19. package/src/SplitPage.tsx +150 -0
  20. package/src/UserMenu.test.tsx +28 -5
  21. package/src/UserMenu.tsx +15 -9
  22. package/src/admin/AuditLogAdmin.tsx +21 -16
  23. package/src/admin/GroupCreate.tsx +18 -4
  24. package/src/admin/GroupDetail.tsx +50 -15
  25. package/src/admin/GroupsAdmin.tsx +13 -5
  26. package/src/admin/UserCreate.tsx +41 -12
  27. package/src/admin/UserDetail.tsx +4 -1
  28. package/src/admin/UsersAdmin.tsx +14 -6
  29. package/src/admin/admin.test.tsx +238 -3
  30. package/src/admin/fieldErrors.ts +45 -0
  31. package/src/bootstrap.test.tsx +208 -0
  32. package/src/bootstrap.tsx +121 -5
  33. package/src/breakpoints.ts +41 -0
  34. package/src/dataview/DataView.tsx +12 -5
  35. package/src/dataview/DataViewCardList.tsx +8 -7
  36. package/src/dataview/DataViewPagination.tsx +15 -8
  37. package/src/dataview/DataViewTable.tsx +32 -21
  38. package/src/dataview/README.md +13 -2
  39. package/src/dataview/index.ts +1 -0
  40. package/src/dataview/internal.tsx +31 -1
  41. package/src/dataview/types.ts +26 -3
  42. package/src/download.test.tsx +153 -0
  43. package/src/download.tsx +132 -0
  44. package/src/files.tsx +2 -11
  45. package/src/format.test.tsx +213 -0
  46. package/src/format.ts +150 -0
  47. package/src/icons.tsx +67 -5
  48. package/src/index.ts +63 -7
  49. package/src/layout.manifest.json +118 -0
  50. package/src/layout.manifest.test.ts +205 -0
  51. package/src/layout.test.tsx +198 -1
  52. package/src/layout.tsx +208 -11
  53. package/src/layoutContract.test.tsx +311 -2
  54. package/src/layoutContract.ts +44 -3
  55. package/src/layoutDeclaration.test.ts +435 -0
  56. package/src/layoutDeclaration.ts +531 -0
  57. package/src/locale.tsx +3 -0
  58. package/src/markers.test.ts +141 -15
  59. package/src/nav.test.ts +234 -4
  60. package/src/nav.ts +180 -6
  61. package/src/navActive.test.ts +115 -0
  62. package/src/navActive.ts +119 -0
  63. package/src/navLink.tsx +20 -2
  64. package/src/previewBridge.test.ts +327 -0
  65. package/src/previewBridge.ts +278 -0
  66. package/src/raw.d.ts +14 -2
  67. package/src/review.test.tsx +272 -0
  68. package/src/routeSearch.ts +73 -0
  69. package/src/routeTypes.ts +50 -6
  70. package/src/router.test.tsx +766 -3
  71. package/src/router.tsx +277 -28
  72. package/src/sso.test.tsx +6 -3
  73. package/src/styles.test.ts +518 -27
  74. package/src/styles.ts +1287 -66
  75. package/src/theme.test.tsx +29 -0
  76. package/src/theme.themes.test.ts +13 -7
  77. package/src/theme.tsx +30 -33
  78. package/src/themes.ts +54 -0
  79. package/src/toast.tsx +2 -1
  80. package/src/tokens.guard.test.ts +192 -0
  81. package/src/typography.test.tsx +213 -0
  82. package/src/typography.tsx +255 -0
  83. package/src/ui/Avatar.test.tsx +63 -0
  84. package/src/ui/Avatar.tsx +65 -0
  85. package/src/ui/Button.test.tsx +71 -3
  86. package/src/ui/Button.tsx +57 -4
  87. package/src/ui/Card.test.tsx +13 -0
  88. package/src/ui/Card.tsx +28 -1
  89. package/src/ui/Checkbox.tsx +10 -2
  90. package/src/ui/Combobox.test.tsx +49 -0
  91. package/src/ui/Combobox.tsx +8 -2
  92. package/src/ui/DatePicker.tsx +28 -5
  93. package/src/ui/Input.test.tsx +123 -0
  94. package/src/ui/Input.tsx +65 -2
  95. package/src/ui/Menu.tsx +16 -5
  96. package/src/ui/Popover.tsx +13 -0
  97. package/src/ui/Radio.tsx +10 -5
  98. package/src/ui/Select.test.tsx +232 -0
  99. package/src/ui/Select.tsx +177 -8
  100. package/src/ui/Switch.tsx +10 -2
  101. package/src/ui/Tabs.tsx +16 -6
  102. package/src/ui/Tooltip.test.tsx +56 -1
  103. package/src/ui/Tooltip.tsx +69 -6
  104. package/src/uiText.tsx +9 -0
  105. package/src/unwrap.test.ts +132 -0
  106. package/src/unwrap.ts +118 -32
@@ -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; a one-off geometry override (LoginView's full-width submit)
43
- // is inline and therefore still wins, which is the boundary ADR 0094 draws between
44
- // styling policy and a measured value.
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 type={type} data-terp="button" data-variant={variant} {...rest}>
33
- {icon !== undefined && (
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
- {icon}
89
+ {leading}
36
90
  </span>
37
91
  )}
38
92
  {children}
39
93
  </button>
40
94
  );
41
95
  }
42
-
@@ -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 {...rest} data-terp="card" data-gap={String(gap)}>
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">
@@ -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
- onChange={(event) => onChange?.(event.currentTarget.checked)}
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>
@@ -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(
@@ -72,11 +72,17 @@ export function Combobox({
72
72
  const isOpen = open && disabled !== true;
73
73
 
74
74
  const renderedOptions = useMemo(() => {
75
- const normalized = query.trim().toLocaleLowerCase();
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).toLocaleLowerCase().includes(normalized));
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;
@@ -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
- function formatDate(date: Date, locale: string | undefined) {
418
- return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric" }).format(date);
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 new Intl.DateTimeFormat(locale, {
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 new Intl.DateTimeFormat(locale, { year: "numeric", month: "long" }).format(date);
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
- return Array.from({ length: 7 }, (_, index) => new Intl.DateTimeFormat(locale, { weekday: "short" }).format(addDays(base, index)));
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(props: InputProps) {
16
- return <input data-terp="input" {...props} />;
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
  }