@terpjs/react-core 0.6.1 → 0.8.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 (79) hide show
  1. package/README.md +12 -2
  2. package/package.json +2 -2
  3. package/src/AppShell.test.tsx +33 -12
  4. package/src/AppShell.tsx +69 -249
  5. package/src/Breadcrumbs.test.tsx +24 -0
  6. package/src/Breadcrumbs.tsx +9 -32
  7. package/src/ConfirmDialog.tsx +13 -44
  8. package/src/EmptyState.tsx +8 -36
  9. package/src/ErrorState.tsx +8 -36
  10. package/src/Field.test.tsx +57 -0
  11. package/src/Field.tsx +46 -22
  12. package/src/HubPage.test.tsx +22 -13
  13. package/src/HubPage.tsx +25 -97
  14. package/src/LoadingState.tsx +3 -24
  15. package/src/ModuleNav.tsx +1 -1
  16. package/src/PageActions.tsx +5 -10
  17. package/src/UserMenu.test.tsx +12 -5
  18. package/src/UserMenu.tsx +33 -62
  19. package/src/dataview/DataView.test.tsx +109 -5
  20. package/src/dataview/DataView.tsx +41 -23
  21. package/src/dataview/DataViewCardList.tsx +14 -60
  22. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  23. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  24. package/src/dataview/DataViewPagination.tsx +2 -32
  25. package/src/dataview/DataViewRowActions.tsx +13 -33
  26. package/src/dataview/DataViewTable.tsx +16 -103
  27. package/src/dataview/DataViewToolbar.tsx +53 -76
  28. package/src/dataview/README.md +6 -0
  29. package/src/dataview/index.ts +1 -0
  30. package/src/dataview/internal.tsx +4 -1
  31. package/src/dataview/types.ts +13 -0
  32. package/src/feedback.test.tsx +26 -0
  33. package/src/files.test.tsx +18 -0
  34. package/src/files.tsx +13 -4
  35. package/src/icons.test.tsx +10 -6
  36. package/src/icons.tsx +33 -37
  37. package/src/index.ts +0 -3
  38. package/src/layout.test.tsx +24 -9
  39. package/src/layout.tsx +24 -21
  40. package/src/layoutContract.test.tsx +95 -0
  41. package/src/locale.tsx +27 -4
  42. package/src/markers.test.ts +468 -0
  43. package/src/raw.d.ts +15 -1
  44. package/src/router.tsx +6 -9
  45. package/src/ssr.test.tsx +1 -3
  46. package/src/styles.test.ts +823 -6
  47. package/src/styles.ts +2699 -153
  48. package/src/theme.test.tsx +39 -0
  49. package/src/theme.themes.test.ts +124 -0
  50. package/src/theme.tsx +62 -14
  51. package/src/toast.tsx +35 -71
  52. package/src/tokens.guard.test.ts +3 -12
  53. package/src/ui/Alert.test.tsx +12 -0
  54. package/src/ui/Alert.tsx +15 -43
  55. package/src/ui/Badge.test.tsx +14 -3
  56. package/src/ui/Badge.tsx +13 -25
  57. package/src/ui/Button.test.tsx +17 -4
  58. package/src/ui/Button.tsx +10 -63
  59. package/src/ui/Card.test.tsx +6 -2
  60. package/src/ui/Card.tsx +11 -39
  61. package/src/ui/Checkbox.tsx +2 -19
  62. package/src/ui/Combobox.test.tsx +22 -0
  63. package/src/ui/Combobox.tsx +31 -80
  64. package/src/ui/DatePicker.test.tsx +131 -4
  65. package/src/ui/DatePicker.tsx +158 -106
  66. package/src/ui/Input.tsx +6 -19
  67. package/src/ui/Markdown.test.tsx +26 -0
  68. package/src/ui/Markdown.tsx +28 -2
  69. package/src/ui/Menu.test.tsx +38 -4
  70. package/src/ui/Menu.tsx +50 -52
  71. package/src/ui/Popover.tsx +53 -19
  72. package/src/ui/Radio.tsx +5 -30
  73. package/src/ui/Select.tsx +7 -30
  74. package/src/ui/Switch.tsx +2 -20
  75. package/src/ui/Tabs.tsx +4 -28
  76. package/src/ui/Textarea.tsx +6 -17
  77. package/src/ui/Tooltip.tsx +9 -21
  78. package/src/uiText.tsx +9 -0
  79. package/src/ui/controlStyles.ts +0 -9
package/src/ui/Menu.tsx CHANGED
@@ -1,49 +1,11 @@
1
1
  import { useRef } from "react";
2
- import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
2
+ import type { KeyboardEvent, ReactNode } from "react";
3
3
 
4
4
  import { Icon } from "../icons";
5
5
  import { useUiText } from "../uiText";
6
6
  import type { UiText } from "../uiText";
7
- import { CONTROL_TEXT_STYLE } from "./controlStyles";
8
7
  import { Popover } from "./Popover";
9
- import type { PopoverAlign, PopoverPlacement } from "./Popover";
10
-
11
- const triggerStyle: CSSProperties = {
12
- ...CONTROL_TEXT_STYLE,
13
- display: "inline-flex",
14
- alignItems: "center",
15
- justifyContent: "center",
16
- minHeight: "2rem",
17
- gap: "var(--space-1)",
18
- padding: "var(--space-1) var(--space-2)",
19
- background: "transparent",
20
- color: "var(--color-neutral-700)",
21
- border: "1px solid var(--color-neutral-300)",
22
- borderRadius: "var(--radius-md)",
23
- cursor: "pointer",
24
- };
25
-
26
- const menuStyle: CSSProperties = { display: "grid", gap: "var(--space-1)" };
27
-
28
- const itemStyle = (destructive: boolean, disabled: boolean): CSSProperties => ({
29
- ...CONTROL_TEXT_STYLE,
30
- display: "flex",
31
- alignItems: "center",
32
- gap: "var(--space-2)",
33
- width: "100%",
34
- padding: "var(--space-2)",
35
- background: "transparent",
36
- border: "none",
37
- borderRadius: "var(--radius-sm)",
38
- textAlign: "left",
39
- justifyContent: "flex-start",
40
- cursor: disabled ? "not-allowed" : "pointer",
41
- color: disabled
42
- ? "var(--color-neutral-300)"
43
- : destructive
44
- ? "var(--color-status-danger)"
45
- : "var(--color-neutral-900)",
46
- });
8
+ import type { PopoverAlign, PopoverPlacement, PopoverRootMarker, PopoverRootVariant } from "./Popover";
47
9
 
48
10
  export interface MenuProps {
49
11
  trigger: ReactNode;
@@ -54,8 +16,16 @@ export interface MenuProps {
54
16
  onOpenChange?: (open: boolean) => void;
55
17
  align?: PopoverAlign;
56
18
  placement?: PopoverPlacement;
57
- triggerStyle?: CSSProperties;
58
- panelStyle?: CSSProperties;
19
+ /**
20
+ * Name this menu's rendered root — which is Popover's wrapper, since a Menu adds no root
21
+ * element of its own. `ThemeToggle`, `LanguageSwitcher` and `UserMenu` each return a bare
22
+ * Menu, so without this their roots all read `data-terp="popover"`.
23
+ */
24
+ "data-terp"?: PopoverRootMarker;
25
+ /** Distinguishes that component's variants on the same root. */
26
+ "data-variant"?: PopoverRootVariant;
27
+ /** Who owns the portalled panel — see PopoverProps; supply it in EVERY variant. */
28
+ "data-owner"?: string;
59
29
  }
60
30
 
61
31
  /** Dropdown menu built on Popover with roving focus and ARIA menu semantics. */
@@ -68,8 +38,9 @@ export function Menu({
68
38
  onOpenChange,
69
39
  align = "end",
70
40
  placement = "bottom",
71
- triggerStyle: triggerStyleOverride,
72
- panelStyle,
41
+ "data-terp": rootMarker,
42
+ "data-variant": rootVariant,
43
+ "data-owner": owner,
73
44
  }: MenuProps) {
74
45
  const resolve = useUiText();
75
46
  const menuRef = useRef<HTMLDivElement>(null);
@@ -97,14 +68,26 @@ export function Menu({
97
68
  onOpenChange={onOpenChange}
98
69
  align={align}
99
70
  placement={placement}
100
- panelStyle={panelStyle}
71
+ data-terp={rootMarker}
72
+ data-variant={rootVariant}
73
+ data-owner={owner}
101
74
  trigger={
75
+ // Its own marker rather than the shared `iconbutton` it used to borrow. That marker
76
+ // is worn by sixteen visually different sites — the shell's two header toggles, four
77
+ // pagination arrows, a toast dismisser, the combobox's clear button, the calendar's
78
+ // month arrows, the DataView's expand toggle, the view-options panel's two reorder
79
+ // arrows and the DataView toolbar's clear-search button and two layout toggles — and it
80
+ // has no base rule at all, because each is styled by where it sits.
81
+ // This one is an outlined control with a border, a radius and control
82
+ // typography, and it is also the trigger whose root marker is about to become
83
+ // configurable, so a `[data-terp="popover"] > [data-terp="iconbutton"]` structural
84
+ // rule would have to grow a branch per root name and would silently stop matching
85
+ // the day one was added. A name of its own has neither problem.
102
86
  <button
103
87
  type="button"
104
- data-terp="iconbutton"
88
+ data-terp="menu-trigger"
105
89
  aria-label={resolve(triggerLabel)}
106
90
  aria-haspopup="menu"
107
- style={{ ...triggerStyle, ...triggerStyleOverride }}
108
91
  >
109
92
  {trigger}
110
93
  </button>
@@ -120,7 +103,6 @@ export function Menu({
120
103
  }}
121
104
  role="menu"
122
105
  data-terp="menu"
123
- style={menuStyle}
124
106
  onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
125
107
  switch (event.key) {
126
108
  case "Escape":
@@ -144,7 +126,16 @@ export function Menu({
144
126
  focusItem("last");
145
127
  break;
146
128
  case "Tab":
147
- close(false);
129
+ // close(TRUE) — restore focus to the trigger, then let Tab's default action
130
+ // run from there. close(false) left focus on a tabIndex=-1 item inside a panel
131
+ // portalled to the END of document.body, and the panel is unmounted before the
132
+ // browser performs the default action: the sequential-navigation starting point
133
+ // was a removed node at the wrong end of the document, so Tab landed past all
134
+ // page content and Shift+Tab at the last focusable element on the page. The APG
135
+ // menu-button contract is that Tab closes the popup and moves to the next
136
+ // element after the BUTTON, which is what restoring focus first produces — in
137
+ // both directions. preventDefault is deliberately absent and must stay absent.
138
+ close(true);
148
139
  break;
149
140
  default:
150
141
  break;
@@ -186,6 +177,10 @@ export function MenuItem({
186
177
  aria-checked={selected}
187
178
  data-terp="menu-item"
188
179
  data-selected={selected === true ? "true" : undefined}
180
+ // Destructive is an enumerable choice, so it becomes an attribute. Disabled is not:
181
+ // this is a real <button> carrying the real disabled attribute, so :disabled already
182
+ // says it and inventing data-disabled beside it would be a second source of truth.
183
+ data-destructive={destructive ? "true" : undefined}
189
184
  tabIndex={-1}
190
185
  disabled={disabled}
191
186
  onClick={() => {
@@ -193,12 +188,15 @@ export function MenuItem({
193
188
  onSelect();
194
189
  }
195
190
  }}
196
- style={itemStyle(destructive, disabled)}
197
191
  >
198
- {icon !== undefined && <span aria-hidden="true" style={{ display: "inline-flex" }}>{icon}</span>}
192
+ {icon !== undefined && (
193
+ <span aria-hidden="true" data-terp="menu-item-icon">
194
+ {icon}
195
+ </span>
196
+ )}
199
197
  {resolve(label)}
200
198
  {selected === true && (
201
- <span aria-hidden="true" style={{ display: "inline-flex", marginLeft: "auto" }}>
199
+ <span aria-hidden="true" data-terp="menu-item-check">
202
200
  <Icon name="check" />
203
201
  </span>
204
202
  )}
@@ -9,6 +9,23 @@ injectTerpStyles();
9
9
  export type PopoverPlacement = "bottom" | "top";
10
10
  export type PopoverAlign = "start" | "end";
11
11
 
12
+ /**
13
+ * The `data-terp` marker a Popover stamps on its rendered root.
14
+ *
15
+ * Three components in the package return a bare `Menu`, whose rendered root is this
16
+ * wrapper — so their root was indistinguishable from any other popover, and none of them
17
+ * could be styled or found. Threading the name through is what lets each say what it is
18
+ * without adding an element to anybody's layout.
19
+ *
20
+ * A closed union rather than `string`, because a marker is framework vocabulary: every value
21
+ * here needs a rule in the sheet and a line in the inventory, and the type says so before a
22
+ * test has to.
23
+ */
24
+ export type PopoverRootMarker = "popover" | "theme-toggle" | "language-switcher" | "user-menu";
25
+
26
+ /** Separates the variants of a component whose root is a Popover. */
27
+ export type PopoverRootVariant = "inline" | "stacked" | "collapsed";
28
+
12
29
  export interface PopoverProps {
13
30
  trigger: ReactElement;
14
31
  children: (api: { close: (restoreFocus?: boolean) => void; panelId: string }) => ReactNode;
@@ -18,24 +35,29 @@ export interface PopoverProps {
18
35
  placement?: PopoverPlacement;
19
36
  align?: PopoverAlign;
20
37
  focusOnOpen?: boolean;
21
- panelStyle?: CSSProperties;
38
+ /**
39
+ * Override the root's marker, for a component whose rendered root this wrapper IS.
40
+ * Named for the attribute it becomes, which is also what keeps the marker inventory
41
+ * honest — the scanner reads `data-terp` sites in component source, so a consumer
42
+ * writing `data-terp="user-menu"` is seen, while a `rootMarker` prop would not be.
43
+ */
44
+ "data-terp"?: PopoverRootMarker;
45
+ /** Distinguishes that component's variants on the same root. */
46
+ "data-variant"?: PopoverRootVariant;
47
+ /**
48
+ * Who owns the portalled panel, for a panel rule that has to reach it.
49
+ *
50
+ * Separate from `data-terp` on purpose. The root marker is necessarily CONDITIONAL — a
51
+ * component whose stacked variant renders its own div must not stamp the marker on the
52
+ * nested menu as well — so deriving the owner from it made the same component's panel
53
+ * `theme-toggle` in one variant and `popover` in the other. A panel rule would then reach
54
+ * one variant and silently miss the other, which is the failure mode the root selector
55
+ * list two doors down exists to warn about. Defaults to the root marker for callers who
56
+ * have no panel rule and do not care.
57
+ */
58
+ "data-owner"?: string;
22
59
  }
23
60
 
24
- const rootStyle: CSSProperties = { position: "relative", display: "inline-flex" };
25
-
26
- const basePanelStyle: CSSProperties = {
27
- position: "fixed",
28
- zIndex: 60,
29
- minWidth: "12rem",
30
- padding: "var(--space-1)",
31
- fontFamily: "var(--font-family-sans)",
32
- color: "var(--color-neutral-900)",
33
- background: "var(--color-neutral-0)",
34
- border: "1px solid var(--color-neutral-200)",
35
- borderRadius: "var(--radius-lg)",
36
- boxShadow: "var(--shadow-lg)",
37
- };
38
-
39
61
  const VIEWPORT_GUTTER = 8;
40
62
  const PANEL_GAP = 4;
41
63
 
@@ -55,7 +77,9 @@ export function Popover({
55
77
  placement = "bottom",
56
78
  align = "end",
57
79
  focusOnOpen = false,
58
- panelStyle,
80
+ "data-terp": rootMarker,
81
+ "data-variant": rootVariant,
82
+ "data-owner": owner,
59
83
  }: PopoverProps) {
60
84
  const panelId = useId();
61
85
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
@@ -199,15 +223,25 @@ export function Popover({
199
223
  } as Partial<typeof trigger.props>);
200
224
 
201
225
  return (
202
- <div ref={rootRef} data-terp="popover" style={rootStyle}>
226
+ <div ref={rootRef} data-terp={rootMarker ?? "popover"} data-variant={rootVariant}>
203
227
  {cloned}
204
228
  {isOpen && createPortal(
205
229
  <div
206
230
  id={panelId}
207
231
  ref={panelRef}
208
232
  data-terp="popover-panel"
233
+ // Whose panel this is. The panel is portalled to document.body, so a descendant
234
+ // selector rooted at the trigger's side of the tree cannot reach it — and a
235
+ // per-caller panel geometry therefore has nowhere to hang unless the panel itself
236
+ // says who owns it. This is what replaced the `panelStyle` prop: the owner's rule
237
+ // lives in the sheet with everything else instead of travelling as an object.
238
+ data-owner={owner ?? rootMarker ?? "popover"}
209
239
  tabIndex={-1}
210
- style={{ ...basePanelStyle, ...panelStyle, ...panelPosition }}
240
+ // Only the measured part is inline: the left/top the layout effect computes from
241
+ // the trigger's rect and clamps against the viewport, and the visibility that
242
+ // hides the panel for the frame before that measurement exists. Everything the
243
+ // panel LOOKS like is a rule (ADR 0094).
244
+ style={panelPosition}
211
245
  >
212
246
  {children({ close, panelId })}
213
247
  </div>,
package/src/ui/Radio.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useId, useState } from "react";
2
- import type { CSSProperties, InputHTMLAttributes, ReactNode } from "react";
2
+ import type { InputHTMLAttributes, ReactNode } from "react";
3
3
 
4
4
  import { injectTerpStyles } from "../styles";
5
5
  import { useUiText } from "../uiText";
@@ -7,30 +7,6 @@ import type { UiText } from "../uiText";
7
7
 
8
8
  injectTerpStyles();
9
9
 
10
- const groupStyle: CSSProperties = { display: "grid", gap: "var(--space-2)", border: 0, padding: 0, margin: 0 };
11
- const legendStyle: CSSProperties = {
12
- fontWeight: "var(--font-weight-medium)" as never,
13
- padding: 0,
14
- marginBlockEnd: "var(--space-1)",
15
- fontSize: "var(--font-size-sm)",
16
- color: "var(--color-neutral-700)",
17
- };
18
- const optionsStyle: CSSProperties = { display: "grid", gap: "var(--space-2)" };
19
- const labelStyle: CSSProperties = {
20
- display: "inline-flex",
21
- alignItems: "center",
22
- gap: "var(--space-2)",
23
- color: "var(--color-neutral-900)",
24
- cursor: "pointer",
25
- fontSize: "var(--font-size-sm)",
26
- };
27
- const inputStyle: CSSProperties = {
28
- inlineSize: "1rem",
29
- blockSize: "1rem",
30
- accentColor: "var(--color-brand-primary)",
31
- cursor: "pointer",
32
- };
33
-
34
10
  export interface RadioProps
35
11
  extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange" | "value"> {
36
12
  label: UiText;
@@ -44,7 +20,7 @@ export interface RadioProps
44
20
  export function Radio({ label, value, checked, defaultChecked, onChange, style, ...rest }: RadioProps) {
45
21
  const resolve = useUiText();
46
22
  return (
47
- <label style={{ ...labelStyle, ...style }}>
23
+ <label data-terp="control-label" style={style}>
48
24
  <input
49
25
  {...rest}
50
26
  type="radio"
@@ -53,7 +29,6 @@ export function Radio({ label, value, checked, defaultChecked, onChange, style,
53
29
  checked={checked}
54
30
  defaultChecked={defaultChecked}
55
31
  onChange={(event) => onChange?.(event.currentTarget.checked)}
56
- style={inputStyle}
57
32
  />
58
33
  <span>{resolve(label)}</span>
59
34
  </label>
@@ -102,9 +77,9 @@ export function RadioGroup({
102
77
  }
103
78
 
104
79
  return (
105
- <fieldset style={groupStyle}>
106
- <legend style={legendStyle}>{resolve(label)}</legend>
107
- <div style={optionsStyle}>
80
+ <fieldset data-terp="radio-group">
81
+ <legend data-terp="radio-group-legend">{resolve(label)}</legend>
82
+ <div data-terp="radio-group-options">
108
83
  {options?.map((option) => (
109
84
  <Radio
110
85
  key={option.value}
package/src/ui/Select.tsx CHANGED
@@ -1,40 +1,17 @@
1
- import type { CSSProperties, SelectHTMLAttributes } from "react";
1
+ import type { SelectHTMLAttributes } from "react";
2
2
 
3
3
  import { injectTerpStyles } from "../styles";
4
- import { CONTROL_TEXT_STYLE } from "./controlStyles";
5
4
 
6
5
  injectTerpStyles();
7
6
 
8
- // SVG chevron rendered via a data URL background — keeps the token-only styling
9
- // contract while giving <select> a consistent affordance across platforms.
10
- const chevronUrl =
11
- "url(\"data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 8 5 5 5-5'/%3E%3C/svg%3E\")";
12
-
13
- const selectStyle: CSSProperties = {
14
- ...CONTROL_TEXT_STYLE,
15
- lineHeight: 1.2,
16
- maxWidth: "100%",
17
- minWidth: 0,
18
- minHeight: "2.25rem",
19
- padding: "0 calc(var(--space-3) + 1.25rem) 0 var(--space-3)",
20
- border: "1px solid var(--color-neutral-300)",
21
- borderRadius: "var(--radius-md)",
22
- color: "var(--color-neutral-900)",
23
- background: `${chevronUrl} no-repeat right var(--space-2) center / 1rem 1rem, var(--color-neutral-0)`,
24
- boxSizing: "border-box",
25
- appearance: "none",
26
- WebkitAppearance: "none",
27
- MozAppearance: "none",
28
- };
29
-
30
7
  export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
31
8
 
32
9
  /**
33
- * Token-styled select — use instead of a raw `<select>` (the module-boundary rule). Pass `<option>`
34
- * children as usual. The `data-terp="input"` marker opts the element into the shared focus
35
- * ring; a subtle SVG chevron replaces the native affordance for a consistent look.
10
+ * Token-styled select — use instead of a raw `<select>` (the module-boundary rule). Pass
11
+ * `<option>` children as usual. The `data-terp="input"` marker opts the element into the
12
+ * shared control surface and focus ring; the sheet replaces the native affordance with an
13
+ * SVG chevron so the control looks the same on every platform (ADR 0094).
36
14
  */
37
- export function Select({ style, ...rest }: SelectProps) {
38
- return <select data-terp="input" {...rest} style={{ ...selectStyle, ...style }} />;
15
+ export function Select(props: SelectProps) {
16
+ return <select data-terp="input" {...props} />;
39
17
  }
40
-
package/src/ui/Switch.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { CSSProperties, InputHTMLAttributes } from "react";
1
+ import type { InputHTMLAttributes } from "react";
2
2
 
3
3
  import { injectTerpStyles } from "../styles";
4
4
  import { useUiText } from "../uiText";
@@ -6,23 +6,6 @@ import type { UiText } from "../uiText";
6
6
 
7
7
  injectTerpStyles();
8
8
 
9
- const labelStyle: CSSProperties = {
10
- display: "inline-flex",
11
- alignItems: "center",
12
- gap: "var(--space-2)",
13
- color: "var(--color-neutral-900)",
14
- cursor: "pointer",
15
- fontSize: "var(--font-size-sm)",
16
- };
17
-
18
- const inputStyle: CSSProperties = {
19
- inlineSize: "2.25rem",
20
- blockSize: "1.25rem",
21
- accentColor: "var(--color-brand-primary)",
22
- cursor: "pointer",
23
- transition: "background-color 150ms ease",
24
- };
25
-
26
9
  export interface SwitchProps
27
10
  extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange" | "role"> {
28
11
  label: UiText;
@@ -35,7 +18,7 @@ export interface SwitchProps
35
18
  export function Switch({ label, checked, defaultChecked, onChange, style, ...rest }: SwitchProps) {
36
19
  const resolve = useUiText();
37
20
  return (
38
- <label style={{ ...labelStyle, ...style }}>
21
+ <label data-terp="control-label" style={style}>
39
22
  <input
40
23
  {...rest}
41
24
  type="checkbox"
@@ -44,7 +27,6 @@ export function Switch({ label, checked, defaultChecked, onChange, style, ...res
44
27
  checked={checked}
45
28
  defaultChecked={defaultChecked}
46
29
  onChange={(event) => onChange?.(event.currentTarget.checked)}
47
- style={inputStyle}
48
30
  />
49
31
  <span>{resolve(label)}</span>
50
32
  </label>
package/src/ui/Tabs.tsx CHANGED
@@ -1,35 +1,12 @@
1
1
  import { useId, useMemo, useState } from "react";
2
- import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
2
+ import type { KeyboardEvent, ReactNode } from "react";
3
3
 
4
4
  import { injectTerpStyles } from "../styles";
5
5
  import { useUiText } from "../uiText";
6
6
  import type { UiText } from "../uiText";
7
- import { CONTROL_TEXT_STYLE } from "./controlStyles";
8
7
 
9
8
  injectTerpStyles();
10
9
 
11
- const rootStyle: CSSProperties = { display: "grid", gap: "var(--space-3)" };
12
- const tabListStyle: CSSProperties = {
13
- display: "flex",
14
- flexWrap: "wrap",
15
- gap: "var(--space-1)",
16
- borderBlockEnd: "1px solid var(--color-neutral-200)",
17
- };
18
- const tabStyle = (selected: boolean): CSSProperties => ({
19
- ...CONTROL_TEXT_STYLE,
20
- fontWeight: (selected
21
- ? "var(--font-weight-semibold)"
22
- : "var(--font-weight-medium)") as never,
23
- padding: "var(--space-2) var(--space-3)",
24
- border: 0,
25
- borderBlockEnd: selected ? "2px solid var(--color-brand-primary)" : "2px solid transparent",
26
- color: selected ? "var(--color-brand-primary)" : "var(--color-neutral-600)",
27
- background: "transparent",
28
- cursor: "pointer",
29
- marginBlockEnd: "-1px",
30
- });
31
- const panelStyle: CSSProperties = { color: "var(--color-neutral-900)" };
32
-
33
10
  export interface TabItem {
34
11
  value: string;
35
12
  label: UiText;
@@ -90,8 +67,8 @@ export function Tabs({ tabs, value, defaultValue, onChange, label }: TabsProps)
90
67
  }
91
68
 
92
69
  return (
93
- <div data-terp="tabs" style={rootStyle}>
94
- <div role="tablist" aria-label={label === undefined ? undefined : resolve(label)} style={tabListStyle} onKeyDown={onKeyDown}>
70
+ <div data-terp="tabs">
71
+ <div role="tablist" data-terp="tab-list" aria-label={label === undefined ? undefined : resolve(label)} onKeyDown={onKeyDown}>
95
72
  {tabs.map((tab) => {
96
73
  const selected = tab.value === selectedTab?.value;
97
74
  return (
@@ -106,7 +83,6 @@ export function Tabs({ tabs, value, defaultValue, onChange, label }: TabsProps)
106
83
  tabIndex={selected ? 0 : -1}
107
84
  disabled={tab.disabled}
108
85
  onClick={() => select(tab.value)}
109
- style={tabStyle(selected)}
110
86
  >
111
87
  {resolve(tab.label)}
112
88
  </button>
@@ -118,7 +94,7 @@ export function Tabs({ tabs, value, defaultValue, onChange, label }: TabsProps)
118
94
  id={`${baseId}-panel-${selectedTab.value}`}
119
95
  role="tabpanel"
120
96
  aria-labelledby={`${baseId}-tab-${selectedTab.value}`}
121
- style={panelStyle}
97
+ data-terp="tab-panel"
122
98
  >
123
99
  {selectedTab.content}
124
100
  </div>
@@ -1,27 +1,16 @@
1
- import type { CSSProperties, TextareaHTMLAttributes } from "react";
1
+ import type { TextareaHTMLAttributes } from "react";
2
2
 
3
3
  import { injectTerpStyles } from "../styles";
4
- import { CONTROL_TEXT_STYLE } from "./controlStyles";
5
4
 
6
5
  injectTerpStyles();
7
6
 
8
- const textareaStyle: CSSProperties = {
9
- ...CONTROL_TEXT_STYLE,
10
- lineHeight: 1.4,
11
- padding: "var(--space-2) var(--space-3)",
12
- border: "1px solid var(--color-neutral-300)",
13
- borderRadius: "var(--radius-md)",
14
- color: "var(--color-neutral-900)",
15
- background: "var(--color-neutral-0)",
16
- boxSizing: "border-box",
17
- };
18
-
19
7
  export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
20
8
 
21
9
  /**
22
- * Token-styled multiline input — use instead of a raw `<textarea>` (the module-boundary rule).
10
+ * Token-styled multiline input — use instead of a raw `<textarea>` (the module-boundary
11
+ * rule). Shares the `data-terp="input"` control surface; the sheet gives it the looser
12
+ * line height and block padding multiline copy needs (ADR 0094).
23
13
  */
24
- export function Textarea({ style, ...rest }: TextareaProps) {
25
- return <textarea data-terp="input" {...rest} style={{ ...textareaStyle, ...style }} />;
14
+ export function Textarea(props: TextareaProps) {
15
+ return <textarea data-terp="input" {...props} />;
26
16
  }
27
-
@@ -1,27 +1,11 @@
1
1
  import { cloneElement, isValidElement, useId, useState } from "react";
2
- import type { CSSProperties, FocusEvent, MouseEvent, ReactElement } from "react";
2
+ import type { FocusEvent, MouseEvent, ReactElement } from "react";
3
3
 
4
+ import { injectTerpStyles } from "../styles";
4
5
  import { useUiText } from "../uiText";
5
6
  import type { UiText } from "../uiText";
6
7
 
7
- const wrapperStyle: CSSProperties = { position: "relative", display: "inline-flex" };
8
- const tooltipStyle: CSSProperties = {
9
- position: "absolute",
10
- zIndex: 1,
11
- insetBlockEnd: "calc(100% + var(--space-1))",
12
- insetInlineStart: 0,
13
- maxInlineSize: "min(18rem, calc(100vw - 2 * var(--space-4)))",
14
- padding: "var(--space-1) var(--space-2)",
15
- borderRadius: "var(--radius-sm)",
16
- color: "var(--color-neutral-0)",
17
- background: "var(--color-neutral-900)",
18
- fontSize: "var(--font-size-xs)",
19
- fontWeight: "var(--font-weight-medium)" as never,
20
- lineHeight: 1.4,
21
- boxShadow: "var(--shadow-md)",
22
- pointerEvents: "none",
23
- whiteSpace: "normal",
24
- };
8
+ injectTerpStyles();
25
9
 
26
10
  export interface TooltipProps {
27
11
  content: UiText;
@@ -47,7 +31,11 @@ export function Tooltip({ content, children }: TooltipProps) {
47
31
  }
48
32
 
49
33
  return (
50
- <span style={wrapperStyle} onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
34
+ <span
35
+ data-terp="tooltip-anchor"
36
+ onMouseEnter={() => setOpen(true)}
37
+ onMouseLeave={() => setOpen(false)}
38
+ >
51
39
  {cloneElement(children, {
52
40
  "aria-describedby": id,
53
41
  onFocus: (event: FocusEvent) => {
@@ -59,7 +47,7 @@ export function Tooltip({ content, children }: TooltipProps) {
59
47
  setOpen(false);
60
48
  },
61
49
  })}
62
- <span id={id} role="tooltip" hidden={!open} style={tooltipStyle}>
50
+ <span id={id} role="tooltip" data-terp="tooltip" hidden={!open}>
63
51
  {resolve(content)}
64
52
  </span>
65
53
  </span>
package/src/uiText.tsx CHANGED
@@ -100,6 +100,12 @@ export interface TerpStrings {
100
100
  themeLight: string;
101
101
  /** {@link ThemeToggle} option: the dark theme. */
102
102
  themeDark: string;
103
+ /** {@link ThemeToggle} option: the near-black dark theme. */
104
+ themeMidnight: string;
105
+ /** {@link ThemeToggle} option: the violet-tinted dark theme. */
106
+ themeTwilight: string;
107
+ /** {@link ThemeToggle} option: the high-contrast theme. */
108
+ themeContrast: string;
103
109
  /** {@link ThemeToggle} option: follow the OS preference. */
104
110
  themeSystem: string;
105
111
  /** Label of the {@link LanguageSwitcher} select. */
@@ -239,6 +245,9 @@ export const DEFAULT_STRINGS: TerpStrings = {
239
245
  theme: "Theme",
240
246
  themeLight: "Light",
241
247
  themeDark: "Dark",
248
+ themeMidnight: "Midnight",
249
+ themeTwilight: "Twilight",
250
+ themeContrast: "High contrast",
242
251
  themeSystem: "System",
243
252
  language: "Language",
244
253
  admin: "Admin",
@@ -1,9 +0,0 @@
1
- import type { CSSProperties } from "react";
2
-
3
- /** Stable typography for interactive controls, independent of surrounding display text. */
4
- export const CONTROL_TEXT_STYLE: CSSProperties = {
5
- fontFamily: "var(--font-family-sans)",
6
- fontSize: "var(--font-size-sm)",
7
- fontWeight: "var(--font-weight-normal)" as CSSProperties["fontWeight"],
8
- lineHeight: 1.25,
9
- };