@terpjs/react-core 0.1.0 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/react-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
6
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@tanstack/react-router": "^1.170.16",
16
- "@terpjs/contract": "^0.1.0"
16
+ "@terpjs/contract": "^0.2.0"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": "^18.3.0 || ^19.0.0",
@@ -374,7 +374,7 @@ describe("DataView embedded variant", () => {
374
374
  expect(screen.getByRole("searchbox")).toBeInTheDocument();
375
375
  expect(screen.queryByText(/of 4 results/)).not.toBeInTheDocument();
376
376
  expect(screen.queryByRole("button", { name: "Card view" })).not.toBeInTheDocument();
377
- expect(screen.queryByRole("combobox", { name: "Rows per page" })).not.toBeInTheDocument();
377
+ expect(screen.queryByRole("button", { name: "Rows per page" })).not.toBeInTheDocument();
378
378
  // All rows rendered — the parent owns paging.
379
379
  expect(screen.getAllByRole("row")).toHaveLength(5);
380
380
  });
@@ -235,7 +235,10 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
235
235
  style={{
236
236
  position: "absolute",
237
237
  top: 0,
238
- right: -3,
238
+ // Sit flush with the cell's right edge — a negative offset would
239
+ // let the last column's handle spill past the table and trip the
240
+ // scroll container's overflow-x, adding a spurious scrollbar.
241
+ right: 0,
239
242
  width: 7,
240
243
  height: "100%",
241
244
  cursor: "col-resize",
@@ -2,10 +2,9 @@ import type { ReactNode } from "react";
2
2
 
3
3
  import { Button } from "../ui/Button";
4
4
  import { Input } from "../ui/Input";
5
- import { Select } from "../ui/Select";
6
5
  import { DataViewColumnSettings } from "./DataViewColumnSettings";
7
6
  import type { DataViewColumnSettingsProps } from "./DataViewColumnSettings";
8
- import { CardsGlyph, CloseGlyph, EllipsisGlyph, SearchGlyph, TableGlyph } from "./glyphs";
7
+ import { CardsGlyph, ChevronDownGlyph, CloseGlyph, EllipsisGlyph, SearchGlyph, TableGlyph } from "./glyphs";
9
8
  import { DataViewMenu, DataViewMenuItem, useDataViewText } from "./internal";
10
9
  import { useViewSearch } from "./hooks/useViewSearch";
11
10
  import type { DataViewBatchAction, DataViewSearchScope } from "./types";
@@ -204,17 +203,29 @@ export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
204
203
  )}
205
204
  <span style={{ flex: 1 }} />
206
205
  {props.onPageSizeChange !== undefined && props.pageSize !== undefined && (
207
- <Select
208
- value={String(props.pageSize)}
209
- aria-label={resolve(strings.pageSize)}
210
- onChange={(event) => props.onPageSizeChange?.(Number(event.target.value))}
206
+ <DataViewMenu
207
+ triggerLabel={resolve(strings.pageSize)}
208
+ trigger={
209
+ <span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)" }}>
210
+ {props.pageSize}
211
+ <ChevronDownGlyph size={14} />
212
+ </span>
213
+ }
211
214
  >
212
- {(props.pageSizeOptions ?? [10, 25, 50, 100]).map((option) => (
213
- <option key={option} value={option}>
214
- {option}
215
- </option>
216
- ))}
217
- </Select>
215
+ {(close) =>
216
+ (props.pageSizeOptions ?? [10, 25, 50, 100]).map((option) => (
217
+ <DataViewMenuItem
218
+ key={option}
219
+ label={String(option)}
220
+ selected={option === props.pageSize}
221
+ onSelect={() => {
222
+ props.onPageSizeChange?.(option);
223
+ close();
224
+ }}
225
+ />
226
+ ))
227
+ }
228
+ </DataViewMenu>
218
229
  )}
219
230
  {props.onLayoutChange !== undefined && (
220
231
  <span style={{ display: "inline-flex", gap: "var(--space-1)" }}>
@@ -75,12 +75,15 @@ export function DataViewMenuItem({
75
75
  label,
76
76
  destructive = false,
77
77
  disabled = false,
78
+ selected,
78
79
  icon,
79
80
  onSelect,
80
81
  }: {
81
82
  label: string;
82
83
  destructive?: boolean;
83
84
  disabled?: boolean;
85
+ /** Marks one choice in a mutually exclusive menu (renders `menuitemradio`). */
86
+ selected?: boolean;
84
87
  icon?: ReactNode;
85
88
  onSelect: () => void;
86
89
  }) {
@@ -88,6 +91,7 @@ export function DataViewMenuItem({
88
91
  <MenuItem
89
92
  label={label}
90
93
  icon={icon}
94
+ selected={selected}
91
95
  destructive={destructive}
92
96
  disabled={disabled}
93
97
  onSelect={onSelect}
@@ -18,4 +18,13 @@ describe("injectTerpStyles", () => {
18
18
  expect(nodes[0]?.textContent ?? "").toContain('[data-terp="input"][type="number"]');
19
19
  expect(nodes[0]?.textContent ?? "").toContain("::-webkit-inner-spin-button");
20
20
  });
21
+
22
+ it("themes scrollbars against the token palette (thin, not the OS default)", () => {
23
+ injectTerpStyles();
24
+ const css = document.querySelector(`style#${TERP_STYLES_ID}`)?.textContent ?? "";
25
+ expect(css).toContain("scrollbar-width: thin");
26
+ expect(css).toContain("scrollbar-color: var(--color-neutral-300) transparent");
27
+ expect(css).toContain("::-webkit-scrollbar");
28
+ expect(css).toContain("::-webkit-scrollbar-thumb");
29
+ });
21
30
  });
package/src/styles.ts CHANGED
@@ -48,6 +48,39 @@ body {
48
48
  box-sizing: border-box;
49
49
  }
50
50
 
51
+ /* Themed scrollbars: the OS default chrome (thick, grey, light-only) ignores
52
+ the app theme and looks foreign against a token-styled surface — most
53
+ obvious on the horizontal overflow of a DataView table. These rules give
54
+ every scroll container a thin, token-coloured bar that tracks light/dark.
55
+ Firefox uses the inheritable scrollbar-* properties (set once on the root);
56
+ WebKit/Blink use the ::-webkit-scrollbar pseudo-elements (not inherited, so
57
+ they match every scrollable element globally). Paired with the color-scheme
58
+ declaration on the token roots so any native chrome we do not restyle here
59
+ (notably the native <select> option popup) also follows the theme. */
60
+ html {
61
+ scrollbar-width: thin;
62
+ scrollbar-color: var(--color-neutral-300) transparent;
63
+ }
64
+ ::-webkit-scrollbar {
65
+ width: 10px;
66
+ height: 10px;
67
+ }
68
+ ::-webkit-scrollbar-track {
69
+ background: transparent;
70
+ }
71
+ ::-webkit-scrollbar-thumb {
72
+ background-color: var(--color-neutral-300);
73
+ border: 2px solid transparent;
74
+ background-clip: padding-box;
75
+ border-radius: var(--radius-full);
76
+ }
77
+ ::-webkit-scrollbar-thumb:hover {
78
+ background-color: var(--color-neutral-400);
79
+ }
80
+ ::-webkit-scrollbar-corner {
81
+ background: transparent;
82
+ }
83
+
51
84
  /* Shared focus-visible ring: every interactive element that opts in via
52
85
  [data-terp] shows a soft outline ring. !important lets the ring beat
53
86
  inline base box-shadows (e.g. the primary button's resting shadow) so
@@ -48,4 +48,14 @@ describe("design tokens", () => {
48
48
  }
49
49
  expect(offenders).toEqual([]);
50
50
  });
51
+
52
+ it("opts native chrome into the palette via color-scheme (light + dark)", () => {
53
+ // Without `color-scheme`, native chrome the framework cannot restyle — the
54
+ // <select> option popup, and any scrollbar a browser draws natively — stays
55
+ // in OS-light rendering even under the dark theme. The light root declares
56
+ // it and both dark blocks (explicit [data-theme='dark'] and the
57
+ // prefers-color-scheme media query) flip it.
58
+ expect(tokensCss).toContain("color-scheme: light");
59
+ expect(tokensCss.match(/color-scheme: dark/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
60
+ });
51
61
  });
@@ -2,7 +2,7 @@
2
2
  import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
 
5
- import { LOCALE_NL, LocaleProvider } from "../locale";
5
+ import { LOCALE_EN, LOCALE_NL, LocaleProvider } from "../locale";
6
6
  import { DatePicker, DateRangePicker } from "./DatePicker";
7
7
 
8
8
  afterEach(cleanup);
@@ -10,7 +10,15 @@ afterEach(cleanup);
10
10
  describe("DatePicker", () => {
11
11
  it("selects a date and supports keyboard navigation", () => {
12
12
  const onChange = vi.fn();
13
- render(<DatePicker aria-label="Due date" defaultValue={new Date(2026, 6, 7)} onChange={onChange} />);
13
+ // Pinned to `en`: without a LocaleProvider the picker formats with
14
+ // `Intl.DateTimeFormat(undefined, …)`, i.e. whatever locale the machine
15
+ // running the suite happens to use — the month name below would then be
16
+ // English only on an English host.
17
+ render(
18
+ <LocaleProvider locales={{ en: LOCALE_EN }}>
19
+ <DatePicker aria-label="Due date" defaultValue={new Date(2026, 6, 7)} onChange={onChange} />
20
+ </LocaleProvider>,
21
+ );
14
22
  fireEvent.click(screen.getByRole("button", { name: "Due date" }));
15
23
  expect(screen.getByRole("grid", { name: /July 2026/ })).toBeInTheDocument();
16
24
  fireEvent.keyDown(screen.getByRole("grid"), { key: "ArrowRight" });