@tristan2828/ui-foundation 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/bin/ui-foundation.mjs +45 -2
- package/dist/components/ui/sidebar.js +1 -1
- package/dist/mocks/auth.d.ts +7 -0
- package/dist/mocks/auth.js +9 -0
- package/dist/mocks/index.d.ts +1 -1
- package/dist/mocks/index.js +1 -1
- package/dist/testing/index.d.ts +15 -3
- package/dist/testing/index.js +29 -3
- package/package.json +34 -12
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# @tristan2828/ui-foundation
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@tristan2828/ui-foundation)
|
|
4
|
+
[](https://github.com/Tristan2828/ui-foundation/actions/workflows/release.yml)
|
|
5
|
+
|
|
3
6
|
The shared layer of a family of small, contract-first, database-backed React
|
|
4
7
|
apps: shadcn primitives, the `DataTable`/`EntityForm` composites, an app
|
|
5
8
|
shell with session-cookie auth, the gateway's error seam, design tokens,
|
|
@@ -18,7 +21,7 @@ than installing this by hand:
|
|
|
18
21
|
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityForm`, `ErrorState`, `MultiChoice`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
|
|
19
22
|
| `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, table, textarea, tooltip |
|
|
20
23
|
| `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
|
|
21
|
-
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `exposeMswForE2E` |
|
|
24
|
+
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
|
|
22
25
|
| `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady` |
|
|
23
26
|
| `@tristan2828/ui-foundation/eslint` | the lint rules, as a flat config: `export default [...uiFoundation()]` |
|
|
24
27
|
| `@tristan2828/ui-foundation/styles.css` | tokens and base styles, after `@import "tailwindcss";` |
|
|
@@ -34,7 +37,8 @@ Run from an app's root:
|
|
|
34
37
|
`sync --check` (in `verify`) fails when they differ from the installed
|
|
35
38
|
version.
|
|
36
39
|
- `ui-foundation check-contract [openapi.yaml]` fails unless the app's spec
|
|
37
|
-
contains the foundation's part unchanged.
|
|
40
|
+
contains the foundation's part unchanged. A path marked `x-optional` may
|
|
41
|
+
be left out, and so may the components only such a path uses.
|
|
38
42
|
- `ui-foundation check-deps` fails if `package.json` names a dependency
|
|
39
43
|
missing from `deps-allowlist.json`.
|
|
40
44
|
|
package/bin/ui-foundation.mjs
CHANGED
|
@@ -160,6 +160,42 @@ function normalise(value) {
|
|
|
160
160
|
|
|
161
161
|
const same = (a, b) => JSON.stringify(normalise(a)) === JSON.stringify(normalise(b))
|
|
162
162
|
|
|
163
|
+
// Every component a value reaches through $refs, following refs inside the
|
|
164
|
+
// components themselves, as 'section/name' keys.
|
|
165
|
+
function reachableComponents(roots, components) {
|
|
166
|
+
const found = new Set()
|
|
167
|
+
const walk = (value) => {
|
|
168
|
+
if (Array.isArray(value)) return value.forEach(walk)
|
|
169
|
+
if (!value || typeof value !== 'object') return
|
|
170
|
+
for (const [key, child] of Object.entries(value)) {
|
|
171
|
+
const match = key === '$ref' && typeof child === 'string' && child.match(/^#\/components\/([^/]+)\/([^/]+)/)
|
|
172
|
+
if (match) {
|
|
173
|
+
const id = `${match[1]}/${match[2]}`
|
|
174
|
+
if (!found.has(id)) {
|
|
175
|
+
found.add(id)
|
|
176
|
+
walk(components[match[1]]?.[match[2]])
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
walk(child)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
roots.forEach(walk)
|
|
184
|
+
return found
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Components reachable from optional paths the app leaves out, and from no
|
|
188
|
+
// path it must (or does) have.
|
|
189
|
+
function optionalOnlyComponents(foundation, appHasPath) {
|
|
190
|
+
const required = []
|
|
191
|
+
const omitted = []
|
|
192
|
+
for (const [route, item] of Object.entries(foundation.paths)) {
|
|
193
|
+
;(item['x-optional'] && !appHasPath(route) ? omitted : required).push(item)
|
|
194
|
+
}
|
|
195
|
+
const requiredIds = reachableComponents(required, foundation.components)
|
|
196
|
+
return new Set([...reachableComponents(omitted, foundation.components)].filter((id) => !requiredIds.has(id)))
|
|
197
|
+
}
|
|
198
|
+
|
|
163
199
|
function checkContract(specPath) {
|
|
164
200
|
const target = path.resolve(APP_ROOT, specPath)
|
|
165
201
|
if (!existsSync(target)) fail(`check-contract: no ${specPath} here`)
|
|
@@ -175,11 +211,18 @@ function checkContract(specPath) {
|
|
|
175
211
|
problems.push(`path ${route} differs`)
|
|
176
212
|
}
|
|
177
213
|
}
|
|
214
|
+
// A component only an omitted optional path uses may be left out with it
|
|
215
|
+
// (RegisterRequest, when there's no /auth/register). Computed from the
|
|
216
|
+
// refs, so a future optional path can't forget to mark its schemas.
|
|
217
|
+
const optionalOnly = optionalOnlyComponents(foundation, (route) => app.paths?.[route] !== undefined)
|
|
178
218
|
for (const [section, entries] of Object.entries(foundation.components)) {
|
|
179
219
|
for (const [name, entry] of Object.entries(entries)) {
|
|
180
220
|
const appEntry = app.components?.[section]?.[name]
|
|
181
|
-
if (appEntry === undefined)
|
|
182
|
-
|
|
221
|
+
if (appEntry === undefined) {
|
|
222
|
+
if (!optionalOnly.has(`${section}/${name}`)) problems.push(`missing components.${section}.${name}`)
|
|
223
|
+
} else if (!same(appEntry, entry)) {
|
|
224
|
+
problems.push(`components.${section}.${name} differs`)
|
|
225
|
+
}
|
|
183
226
|
}
|
|
184
227
|
}
|
|
185
228
|
|
|
@@ -132,7 +132,7 @@ function SidebarGroupLabel({ className, render, ...props }) {
|
|
|
132
132
|
return useRender({
|
|
133
133
|
defaultTagName: "div",
|
|
134
134
|
props: mergeProps({
|
|
135
|
-
className: cn("flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", className),
|
|
135
|
+
className: cn("flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", className),
|
|
136
136
|
}, props),
|
|
137
137
|
render,
|
|
138
138
|
state: {
|
package/dist/mocks/auth.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ export declare const MOCK_USER: User;
|
|
|
4
4
|
export declare const MOCK_PASSWORD = "dev-password-123";
|
|
5
5
|
export declare function isMockAuthenticated(): boolean;
|
|
6
6
|
export declare function setMockAuthenticated(value: boolean): void;
|
|
7
|
+
/**
|
|
8
|
+
* The user the mock session is signed in as, or null when signed out — the
|
|
9
|
+
* same answer GET /auth/me gives. For an app's own handlers that act as the
|
|
10
|
+
* signed-in user (per-user rows): after a mock registration this is the new
|
|
11
|
+
* user, not MOCK_USER. On null, answer 401 as /auth/me does.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getMockCurrentUser(): User | null;
|
|
7
14
|
export declare function resetMockAuth(): void;
|
|
8
15
|
export declare const authHandlers: import("msw").HttpHandler[];
|
|
9
16
|
export {};
|
package/dist/mocks/auth.js
CHANGED
|
@@ -22,6 +22,15 @@ export function isMockAuthenticated() {
|
|
|
22
22
|
export function setMockAuthenticated(value) {
|
|
23
23
|
authenticated = value;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The user the mock session is signed in as, or null when signed out — the
|
|
27
|
+
* same answer GET /auth/me gives. For an app's own handlers that act as the
|
|
28
|
+
* signed-in user (per-user rows): after a mock registration this is the new
|
|
29
|
+
* user, not MOCK_USER. On null, answer 401 as /auth/me does.
|
|
30
|
+
*/
|
|
31
|
+
export function getMockCurrentUser() {
|
|
32
|
+
return authenticated ? currentUser : null;
|
|
33
|
+
}
|
|
25
34
|
export function resetMockAuth() {
|
|
26
35
|
authenticated = true;
|
|
27
36
|
registeredUsers = new Map([[MOCK_USER.email, MOCK_USER]]);
|
package/dist/mocks/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { authHandlers, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
1
|
+
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
2
2
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
|
3
3
|
export type { MswHandle, MswOverride } from './override.js';
|
package/dist/mocks/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `@tristan2828/ui-foundation/mocks` — MSW support. A separate entry point
|
|
2
2
|
// so msw (an optional peer dependency) never reaches an app's main bundle
|
|
3
3
|
// unless it imports this.
|
|
4
|
-
export { authHandlers, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
4
|
+
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
5
5
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -30,11 +30,23 @@ export type A11ySuiteOptions = {
|
|
|
30
30
|
* emulateMedia switches it.
|
|
31
31
|
*/
|
|
32
32
|
export declare function defineA11ySuite({ formRoutes, loggedOutRoutes }: A11ySuiteOptions): void;
|
|
33
|
+
export type MockModeBannerSuiteOptions = {
|
|
34
|
+
/** Screens to check, logged-out ones included (e.g. '/login'). */
|
|
35
|
+
routes: readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* Whether this run's app is MSW-backed. Defaults to
|
|
38
|
+
* `process.env.VITE_API !== 'real'`, the same switch the app is built with.
|
|
39
|
+
* When false (a run against the real backend) the banner must be absent.
|
|
40
|
+
*/
|
|
41
|
+
mockMode?: boolean;
|
|
42
|
+
};
|
|
33
43
|
/**
|
|
34
44
|
* Mock mode is announced on every route, logged-out ones included — /login
|
|
35
45
|
* is where a mock session passing for a real one first cost someone time.
|
|
36
46
|
* The default Playwright run is MSW-backed, so the banner must be there.
|
|
47
|
+
*
|
|
48
|
+
* Against the real backend (`VITE_API=real`) it asserts the opposite: no
|
|
49
|
+
* banner on any route. That half catches a mock bundle shipped as if it
|
|
50
|
+
* were production, which is what the banner exists for.
|
|
37
51
|
*/
|
|
38
|
-
export declare function defineMockModeBannerSuite({ routes }:
|
|
39
|
-
routes: readonly string[];
|
|
40
|
-
}): void;
|
|
52
|
+
export declare function defineMockModeBannerSuite({ routes, mockMode, }: MockModeBannerSuiteOptions): void;
|
package/dist/testing/index.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// calls a suite with its own routes, instead of carrying a copy of the spec
|
|
4
4
|
// to edit (each of these was forked by the first real app for exactly that).
|
|
5
5
|
//
|
|
6
|
+
// Call a suite inside the spec file's own `test.describe(...)`. Playwright
|
|
7
|
+
// locates a test where test() is called, which for a suite is this package;
|
|
8
|
+
// a file argument (`playwright test e2e/a11y.spec.ts`) keeps a test only if
|
|
9
|
+
// it or an enclosing describe is located in that file. Without the app's
|
|
10
|
+
// own describe, selecting the spec by file runs nothing.
|
|
11
|
+
//
|
|
6
12
|
// Runs in Node, inside Playwright: @playwright/test and @axe-core/playwright
|
|
7
13
|
// are optional peer dependencies, needed only by apps that import this.
|
|
8
14
|
import AxeBuilder from '@axe-core/playwright';
|
|
@@ -91,13 +97,33 @@ export function defineA11ySuite({ formRoutes, loggedOutRoutes }) {
|
|
|
91
97
|
* Mock mode is announced on every route, logged-out ones included — /login
|
|
92
98
|
* is where a mock session passing for a real one first cost someone time.
|
|
93
99
|
* The default Playwright run is MSW-backed, so the banner must be there.
|
|
100
|
+
*
|
|
101
|
+
* Against the real backend (`VITE_API=real`) it asserts the opposite: no
|
|
102
|
+
* banner on any route. That half catches a mock bundle shipped as if it
|
|
103
|
+
* were production, which is what the banner exists for.
|
|
94
104
|
*/
|
|
95
|
-
export function defineMockModeBannerSuite({ routes }) {
|
|
96
|
-
|
|
105
|
+
export function defineMockModeBannerSuite({ routes, mockMode = process.env.VITE_API !== 'real', }) {
|
|
106
|
+
if (mockMode) {
|
|
107
|
+
test('mock mode is announced on every route, including logged-out ones', async ({ page }) => {
|
|
108
|
+
const banner = page.getByRole('status').filter({ hasText: 'Mock data' });
|
|
109
|
+
for (const route of routes) {
|
|
110
|
+
await page.goto(route);
|
|
111
|
+
await test.step(route, () => expect(banner).toBeVisible());
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
test('no mock-mode banner on any route against the real backend', async ({ page }) => {
|
|
97
117
|
const banner = page.getByRole('status').filter({ hasText: 'Mock data' });
|
|
98
118
|
for (const route of routes) {
|
|
99
119
|
await page.goto(route);
|
|
100
|
-
await test.step(route, () =>
|
|
120
|
+
await test.step(route, async () => {
|
|
121
|
+
// The banner renders beside the router, so once a screen's <main>
|
|
122
|
+
// is on the page the banner would be too. Without this wait a
|
|
123
|
+
// blank page would pass the count check trivially.
|
|
124
|
+
await expect(page.getByRole('main')).toBeVisible();
|
|
125
|
+
await expect(banner).toHaveCount(0);
|
|
126
|
+
});
|
|
101
127
|
}
|
|
102
128
|
});
|
|
103
129
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -91,17 +91,39 @@
|
|
|
91
91
|
"zod": "^4.6.5"
|
|
92
92
|
},
|
|
93
93
|
"peerDependenciesMeta": {
|
|
94
|
-
"@axe-core/playwright": {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"@
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
"
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"
|
|
104
|
-
|
|
94
|
+
"@axe-core/playwright": {
|
|
95
|
+
"optional": true
|
|
96
|
+
},
|
|
97
|
+
"@eslint/js": {
|
|
98
|
+
"optional": true
|
|
99
|
+
},
|
|
100
|
+
"@playwright/test": {
|
|
101
|
+
"optional": true
|
|
102
|
+
},
|
|
103
|
+
"@tanstack/eslint-plugin-query": {
|
|
104
|
+
"optional": true
|
|
105
|
+
},
|
|
106
|
+
"eslint": {
|
|
107
|
+
"optional": true
|
|
108
|
+
},
|
|
109
|
+
"eslint-plugin-check-file": {
|
|
110
|
+
"optional": true
|
|
111
|
+
},
|
|
112
|
+
"eslint-plugin-react-hooks": {
|
|
113
|
+
"optional": true
|
|
114
|
+
},
|
|
115
|
+
"eslint-plugin-react-refresh": {
|
|
116
|
+
"optional": true
|
|
117
|
+
},
|
|
118
|
+
"globals": {
|
|
119
|
+
"optional": true
|
|
120
|
+
},
|
|
121
|
+
"msw": {
|
|
122
|
+
"optional": true
|
|
123
|
+
},
|
|
124
|
+
"typescript-eslint": {
|
|
125
|
+
"optional": true
|
|
126
|
+
}
|
|
105
127
|
},
|
|
106
128
|
"dependencies": {
|
|
107
129
|
"@fontsource-variable/geist": "^5.3.0",
|