@terpjs/react-core 0.7.0 → 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.
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/PageActions.tsx +5 -10
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +13 -4
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +9 -37
- package/src/index.ts +0 -3
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +24 -4
- package/src/markers.test.ts +242 -19
- package/src/router.tsx +6 -9
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +823 -6
- package/src/styles.ts +2699 -153
- package/src/theme.tsx +24 -3
- package/src/toast.tsx +35 -71
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +9 -28
- package/src/ui/Button.test.tsx +17 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- 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 {
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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="
|
|
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(
|
|
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 &&
|
|
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"
|
|
199
|
+
<span aria-hidden="true" data-terp="menu-item-check">
|
|
202
200
|
<Icon name="check" />
|
|
203
201
|
</span>
|
|
204
202
|
)}
|
package/src/ui/Popover.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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 {
|
|
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-fg-accent)",
|
|
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={
|
|
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
|
|
106
|
-
<legend
|
|
107
|
-
<div
|
|
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 {
|
|
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
|
|
34
|
-
* children as usual. The `data-terp="input"` marker opts the element into the
|
|
35
|
-
* ring;
|
|
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(
|
|
38
|
-
return <select data-terp="input" {...
|
|
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 {
|
|
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-fg-accent)",
|
|
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={
|
|
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 {
|
|
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-fg-accent)" : "2px solid transparent",
|
|
26
|
-
color: selected ? "var(--color-fg-accent)" : "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"
|
|
94
|
-
<div role="tablist" aria-label={label === undefined ? undefined : resolve(label)}
|
|
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
|
-
|
|
97
|
+
data-terp="tab-panel"
|
|
122
98
|
>
|
|
123
99
|
{selectedTab.content}
|
|
124
100
|
</div>
|
package/src/ui/Textarea.tsx
CHANGED
|
@@ -1,27 +1,16 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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(
|
|
25
|
-
return <textarea data-terp="input" {...
|
|
14
|
+
export function Textarea(props: TextareaProps) {
|
|
15
|
+
return <textarea data-terp="input" {...props} />;
|
|
26
16
|
}
|
|
27
|
-
|
package/src/ui/Tooltip.tsx
CHANGED
|
@@ -1,27 +1,11 @@
|
|
|
1
1
|
import { cloneElement, isValidElement, useId, useState } from "react";
|
|
2
|
-
import type {
|
|
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
|
-
|
|
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
|
|
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" data-terp="tooltip" hidden={!open}
|
|
50
|
+
<span id={id} role="tooltip" data-terp="tooltip" hidden={!open}>
|
|
63
51
|
{resolve(content)}
|
|
64
52
|
</span>
|
|
65
53
|
</span>
|
package/src/ui/controlStyles.ts
DELETED
|
@@ -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
|
-
};
|