@startsimpli/ui 0.4.15 → 0.4.17

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": "@startsimpli/ui",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -10,6 +10,7 @@
10
10
  "./gantt": "./src/components/gantt/index.ts",
11
11
  "./gantt/styles": "./src/components/gantt/gantt.css",
12
12
  "./components": "./src/components/index.ts",
13
+ "./error": "./src/components/error/index.ts",
13
14
  "./utils": "./src/utils/index.ts",
14
15
  "./theme": "./src/theme/index.ts",
15
16
  "./theme/contract": "./theme/contract.css",
@@ -0,0 +1,70 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { RouteErrorBoundary } from '../error/RouteErrorBoundary'
3
+ import { NotFound } from '../error/NotFound'
4
+
5
+ describe('RouteErrorBoundary', () => {
6
+ const makeError = () => Object.assign(new Error('boom'), { digest: 'abc123' })
7
+
8
+ it('renders default title and description', () => {
9
+ render(<RouteErrorBoundary error={makeError()} reset={jest.fn()} />)
10
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument()
11
+ expect(screen.getByRole('alert')).toHaveAttribute('aria-live', 'assertive')
12
+ })
13
+
14
+ it('calls reset when the retry button is clicked', () => {
15
+ const reset = jest.fn()
16
+ render(<RouteErrorBoundary error={makeError()} reset={reset} />)
17
+ fireEvent.click(screen.getByText('Try again'))
18
+ expect(reset).toHaveBeenCalledTimes(1)
19
+ })
20
+
21
+ it('invokes onError once on mount', () => {
22
+ const onError = jest.fn()
23
+ const error = makeError()
24
+ render(<RouteErrorBoundary error={error} reset={jest.fn()} onError={onError} />)
25
+ expect(onError).toHaveBeenCalledTimes(1)
26
+ expect(onError).toHaveBeenCalledWith(error)
27
+ })
28
+
29
+ it('hides the home button when homeHref is null', () => {
30
+ render(
31
+ <RouteErrorBoundary error={makeError()} reset={jest.fn()} homeHref={null} />
32
+ )
33
+ expect(screen.queryByText('Go home')).not.toBeInTheDocument()
34
+ })
35
+
36
+ it('supports a render-prop override via children', () => {
37
+ render(
38
+ <RouteErrorBoundary error={makeError()} reset={jest.fn()}>
39
+ {({ error }) => <div>custom: {error.message}</div>}
40
+ </RouteErrorBoundary>
41
+ )
42
+ expect(screen.getByText('custom: boom')).toBeInTheDocument()
43
+ expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
44
+ })
45
+ })
46
+
47
+ describe('NotFound', () => {
48
+ it('renders code, title, and description', () => {
49
+ render(<NotFound />)
50
+ expect(screen.getByText('404')).toBeInTheDocument()
51
+ expect(screen.getByText('Page not found')).toBeInTheDocument()
52
+ })
53
+
54
+ it('renders a default home anchor when no action is given', () => {
55
+ render(<NotFound />)
56
+ const link = screen.getByText('Go home')
57
+ expect(link).toHaveAttribute('href', '/')
58
+ })
59
+
60
+ it('renders a custom action node', () => {
61
+ render(<NotFound action={<a href="/browse">Browse</a>} />)
62
+ expect(screen.getByText('Browse')).toBeInTheDocument()
63
+ expect(screen.queryByText('Go home')).not.toBeInTheDocument()
64
+ })
65
+
66
+ it('hides the code when set to null', () => {
67
+ render(<NotFound code={null} />)
68
+ expect(screen.queryByText('404')).not.toBeInTheDocument()
69
+ })
70
+ })
@@ -0,0 +1,261 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import type { RouteError } from './RouteErrorBoundary'
5
+
6
+ export interface GlobalErrorProps {
7
+ /** The error thrown during render — matches the Next.js `global-error.tsx` contract. */
8
+ error: RouteError
9
+ /** Re-render the root — matches the Next.js `global-error.tsx` contract. */
10
+ reset: () => void
11
+ /** `lang` attribute for the replacement `<html>` element. */
12
+ lang?: string
13
+ /** Document title shown while the global error surface is active. */
14
+ pageTitle?: string
15
+ /** Heading shown above the message. */
16
+ title?: string
17
+ /** Body copy shown to users. */
18
+ description?: string
19
+ /** Label for the primary (retry) action. */
20
+ retryLabel?: string
21
+ /** Label for the secondary (home) action. Hidden when `homeHref` is null. */
22
+ homeLabel?: string
23
+ /** Destination for the secondary action. Pass `null` to hide it. Defaults to `/`. */
24
+ homeHref?: string | null
25
+ /** Accent color used for the primary button. Defaults to a neutral blue. */
26
+ accentColor?: string
27
+ /** Called once on mount with the error — wire up your logger/Sentry here. */
28
+ onError?: (error: RouteError) => void
29
+ }
30
+
31
+ /**
32
+ * Standalone global error surface for an app's `app/global-error.tsx`.
33
+ *
34
+ * Because `global-error.tsx` replaces the root layout, this renders its own
35
+ * `<html>`/`<body>` and uses inline styles only — it does NOT depend on the
36
+ * app's Tailwind/CSS being loaded. Brandable via the `accentColor` and copy
37
+ * props.
38
+ *
39
+ * @example
40
+ * // app/global-error.tsx
41
+ * 'use client'
42
+ * import { GlobalError } from '@startsimpli/ui'
43
+ * export default function Error(props: { error: Error & { digest?: string }; reset: () => void }) {
44
+ * return <GlobalError {...props} accentColor="#f86c4f" />
45
+ * }
46
+ */
47
+ export function GlobalError({
48
+ error,
49
+ reset,
50
+ lang = 'en',
51
+ pageTitle = 'Application error',
52
+ title = 'Application error',
53
+ description = 'We encountered a problem loading the application. Please try refreshing the page, or contact support if the issue persists.',
54
+ retryLabel = 'Try again',
55
+ homeLabel = 'Go home',
56
+ homeHref = '/',
57
+ accentColor = '#2563eb',
58
+ onError,
59
+ }: GlobalErrorProps) {
60
+ React.useEffect(() => {
61
+ onError?.(error)
62
+ }, [error, onError])
63
+
64
+ const isDevelopment = process.env.NODE_ENV === 'development'
65
+
66
+ const goHome = () => {
67
+ if (homeHref && typeof window !== 'undefined') {
68
+ window.location.href = homeHref
69
+ }
70
+ }
71
+
72
+ return (
73
+ <html lang={lang}>
74
+ <head>
75
+ <meta charSet="utf-8" />
76
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
77
+ <title>{pageTitle}</title>
78
+ </head>
79
+ <body
80
+ style={{
81
+ margin: 0,
82
+ minHeight: '100vh',
83
+ display: 'flex',
84
+ alignItems: 'center',
85
+ justifyContent: 'center',
86
+ padding: '1rem',
87
+ backgroundColor: '#f9fafb',
88
+ color: '#1f2937',
89
+ fontFamily:
90
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
91
+ lineHeight: 1.5,
92
+ }}
93
+ >
94
+ <div
95
+ style={{
96
+ maxWidth: '36rem',
97
+ width: '100%',
98
+ background: '#ffffff',
99
+ borderRadius: '0.75rem',
100
+ boxShadow: '0 10px 25px rgba(0, 0, 0, 0.1)',
101
+ border: '1px solid #e5e7eb',
102
+ padding: '2rem',
103
+ textAlign: 'center',
104
+ }}
105
+ role="alert"
106
+ >
107
+ <div
108
+ style={{
109
+ width: '4rem',
110
+ height: '4rem',
111
+ margin: '0 auto 1.5rem',
112
+ borderRadius: '50%',
113
+ backgroundColor: '#fee2e2',
114
+ display: 'flex',
115
+ alignItems: 'center',
116
+ justifyContent: 'center',
117
+ }}
118
+ aria-hidden="true"
119
+ >
120
+ <svg
121
+ width="32"
122
+ height="32"
123
+ viewBox="0 0 24 24"
124
+ fill="none"
125
+ stroke="#dc2626"
126
+ strokeWidth="2"
127
+ strokeLinecap="round"
128
+ strokeLinejoin="round"
129
+ >
130
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
131
+ <line x1="12" y1="9" x2="12" y2="13" />
132
+ <line x1="12" y1="17" x2="12.01" y2="17" />
133
+ </svg>
134
+ </div>
135
+
136
+ <h1
137
+ style={{
138
+ fontSize: '1.75rem',
139
+ fontWeight: 700,
140
+ margin: '0 0 0.75rem',
141
+ }}
142
+ >
143
+ {title}
144
+ </h1>
145
+ <p style={{ color: '#6b7280', margin: '0 0 1.5rem' }}>{description}</p>
146
+
147
+ {isDevelopment && (
148
+ <div
149
+ style={{
150
+ backgroundColor: '#fef2f2',
151
+ border: '1px solid #fecaca',
152
+ borderRadius: '0.5rem',
153
+ padding: '1rem',
154
+ marginBottom: '1.5rem',
155
+ textAlign: 'left',
156
+ }}
157
+ >
158
+ <p
159
+ style={{
160
+ fontSize: '0.75rem',
161
+ fontWeight: 600,
162
+ color: '#7f1d1d',
163
+ margin: '0 0 0.5rem',
164
+ }}
165
+ >
166
+ Error (development only)
167
+ </p>
168
+ <p
169
+ style={{
170
+ fontFamily: 'monospace',
171
+ fontSize: '0.875rem',
172
+ color: '#991b1b',
173
+ wordBreak: 'break-word',
174
+ margin: 0,
175
+ }}
176
+ >
177
+ {error.message}
178
+ </p>
179
+ {error.digest && (
180
+ <p
181
+ style={{
182
+ fontFamily: 'monospace',
183
+ fontSize: '0.75rem',
184
+ color: '#991b1b',
185
+ marginTop: '0.5rem',
186
+ }}
187
+ >
188
+ Digest: {error.digest}
189
+ </p>
190
+ )}
191
+ </div>
192
+ )}
193
+
194
+ {!isDevelopment && error.digest && (
195
+ <p
196
+ style={{
197
+ fontSize: '0.75rem',
198
+ color: '#6b7280',
199
+ marginBottom: '1.5rem',
200
+ }}
201
+ >
202
+ Reference:{' '}
203
+ <code
204
+ style={{
205
+ fontFamily: 'monospace',
206
+ backgroundColor: '#f3f4f6',
207
+ padding: '0.125rem 0.375rem',
208
+ borderRadius: '0.25rem',
209
+ }}
210
+ >
211
+ {error.digest}
212
+ </code>
213
+ </p>
214
+ )}
215
+
216
+ <div
217
+ style={{
218
+ display: 'flex',
219
+ flexWrap: 'wrap',
220
+ gap: '0.75rem',
221
+ justifyContent: 'center',
222
+ }}
223
+ >
224
+ <button
225
+ onClick={reset}
226
+ style={{
227
+ padding: '0.625rem 1.25rem',
228
+ borderRadius: '0.5rem',
229
+ border: 'none',
230
+ cursor: 'pointer',
231
+ fontWeight: 600,
232
+ fontSize: '0.95rem',
233
+ color: '#ffffff',
234
+ backgroundColor: accentColor,
235
+ }}
236
+ >
237
+ {retryLabel}
238
+ </button>
239
+ {homeHref && (
240
+ <button
241
+ onClick={goHome}
242
+ style={{
243
+ padding: '0.625rem 1.25rem',
244
+ borderRadius: '0.5rem',
245
+ border: 'none',
246
+ cursor: 'pointer',
247
+ fontWeight: 600,
248
+ fontSize: '0.95rem',
249
+ color: '#374151',
250
+ backgroundColor: '#f3f4f6',
251
+ }}
252
+ >
253
+ {homeLabel}
254
+ </button>
255
+ )}
256
+ </div>
257
+ </div>
258
+ </body>
259
+ </html>
260
+ )
261
+ }
@@ -0,0 +1,78 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+
4
+ export interface NotFoundProps {
5
+ /** Large code shown above the title (e.g. "404"). Pass null to hide. */
6
+ code?: string | null
7
+ /** Heading. */
8
+ title?: string
9
+ /** Body copy. */
10
+ description?: string
11
+ /**
12
+ * Primary action. Provide your own element (e.g. a Next `<Link>`) so the
13
+ * package stays free of a hard `next/link` dependency. Defaults to a plain
14
+ * anchor to `/`.
15
+ */
16
+ action?: React.ReactNode
17
+ /** Override the outer wrapper className. */
18
+ className?: string
19
+ /** Extra content rendered below the action (e.g. a secondary link). */
20
+ children?: React.ReactNode
21
+ }
22
+
23
+ /**
24
+ * Default 404 surface for an app's `app/not-found.tsx`.
25
+ *
26
+ * Neutral, brandable, and SSR-safe (no client hooks). Pass an `action` node —
27
+ * typically a Next `<Link>` — for app-aware navigation.
28
+ *
29
+ * @example
30
+ * // app/not-found.tsx
31
+ * import Link from 'next/link'
32
+ * import { NotFound } from '@startsimpli/ui'
33
+ * export default function Page() {
34
+ * return (
35
+ * <NotFound action={<Link href="/" className="...">Go home</Link>} />
36
+ * )
37
+ * }
38
+ */
39
+ export function NotFound({
40
+ code = '404',
41
+ title = 'Page not found',
42
+ description = "The page you're looking for doesn't exist or has been moved.",
43
+ action,
44
+ className,
45
+ children,
46
+ }: NotFoundProps) {
47
+ return (
48
+ <div
49
+ className={cn(
50
+ 'min-h-[60vh] flex items-center justify-center p-4 text-center',
51
+ className
52
+ )}
53
+ role="status"
54
+ aria-live="polite"
55
+ >
56
+ <div className="max-w-md w-full">
57
+ {code != null && (
58
+ <p className="mb-2 text-6xl font-bold tracking-tight text-muted-foreground">
59
+ {code}
60
+ </p>
61
+ )}
62
+ <h1 className="mb-3 text-2xl font-semibold text-foreground">{title}</h1>
63
+ <p className="mb-8 text-sm text-muted-foreground">{description}</p>
64
+ <div className="flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
65
+ {action ?? (
66
+ <a
67
+ href="/"
68
+ className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
69
+ >
70
+ Go home
71
+ </a>
72
+ )}
73
+ </div>
74
+ {children}
75
+ </div>
76
+ </div>
77
+ )
78
+ }
@@ -0,0 +1,175 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+
6
+ export interface RouteError extends Error {
7
+ digest?: string
8
+ }
9
+
10
+ export interface RouteErrorBoundaryProps {
11
+ /** The error thrown during render — matches the Next.js `error.tsx` contract. */
12
+ error: RouteError
13
+ /** Re-render the route segment — matches the Next.js `error.tsx` contract. */
14
+ reset: () => void
15
+ /** Heading shown above the message. */
16
+ title?: string
17
+ /** Body copy shown in production (the raw error is only shown in development). */
18
+ description?: string
19
+ /** Label for the primary (retry) action. */
20
+ retryLabel?: string
21
+ /** Label for the secondary (home) action. Hidden when `homeHref` is null. */
22
+ homeLabel?: string
23
+ /** Destination for the secondary action. Pass `null` to hide it. Defaults to `/`. */
24
+ homeHref?: string | null
25
+ /**
26
+ * Render-prop override for the entire surface. Receives the error plus the
27
+ * resolved `reset`/`goHome` handlers so apps can fully brand the page while
28
+ * keeping the Next.js wiring.
29
+ */
30
+ children?: (props: {
31
+ error: RouteError
32
+ reset: () => void
33
+ goHome: () => void
34
+ }) => React.ReactNode
35
+ /** Called once on mount with the error — wire up your logger/Sentry here. */
36
+ onError?: (error: RouteError) => void
37
+ /** Override the outer wrapper className. */
38
+ className?: string
39
+ }
40
+
41
+ /**
42
+ * Route-level error surface for an app's `app/error.tsx`.
43
+ *
44
+ * Implements the Next.js App Router error contract (`{ error, reset }`) and
45
+ * renders a neutral, brandable error card. Stack/digest details are only shown
46
+ * in development. Use render-prop `children` for full custom UI.
47
+ *
48
+ * @example
49
+ * // app/error.tsx
50
+ * 'use client'
51
+ * import { RouteErrorBoundary } from '@startsimpli/ui'
52
+ * export default function Error(props: { error: Error & { digest?: string }; reset: () => void }) {
53
+ * return <RouteErrorBoundary {...props} />
54
+ * }
55
+ */
56
+ export function RouteErrorBoundary({
57
+ error,
58
+ reset,
59
+ title = 'Something went wrong',
60
+ description = 'We encountered an unexpected error. Please try again, or head back home if the problem persists.',
61
+ retryLabel = 'Try again',
62
+ homeLabel = 'Go home',
63
+ homeHref = '/',
64
+ children,
65
+ onError,
66
+ className,
67
+ }: RouteErrorBoundaryProps) {
68
+ React.useEffect(() => {
69
+ onError?.(error)
70
+ }, [error, onError])
71
+
72
+ const goHome = React.useCallback(() => {
73
+ if (homeHref && typeof window !== 'undefined') {
74
+ window.location.href = homeHref
75
+ }
76
+ }, [homeHref])
77
+
78
+ if (children) {
79
+ return <>{children({ error, reset, goHome })}</>
80
+ }
81
+
82
+ const isDevelopment = process.env.NODE_ENV === 'development'
83
+
84
+ return (
85
+ <div
86
+ className={cn(
87
+ 'min-h-[60vh] flex items-center justify-center p-4',
88
+ className
89
+ )}
90
+ role="alert"
91
+ aria-live="assertive"
92
+ >
93
+ <div className="max-w-lg w-full rounded-xl border border-border bg-background p-8 shadow-sm text-center">
94
+ <div
95
+ className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-red-50"
96
+ aria-hidden="true"
97
+ >
98
+ <svg
99
+ xmlns="http://www.w3.org/2000/svg"
100
+ width="32"
101
+ height="32"
102
+ viewBox="0 0 24 24"
103
+ fill="none"
104
+ stroke="currentColor"
105
+ strokeWidth="2"
106
+ strokeLinecap="round"
107
+ strokeLinejoin="round"
108
+ className="text-red-500"
109
+ >
110
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
111
+ <line x1="12" y1="9" x2="12" y2="13" />
112
+ <line x1="12" y1="17" x2="12.01" y2="17" />
113
+ </svg>
114
+ </div>
115
+
116
+ <h1 className="text-2xl font-semibold text-foreground mb-3">{title}</h1>
117
+ <p className="text-sm text-muted-foreground mb-6 max-w-md mx-auto">
118
+ {description}
119
+ </p>
120
+
121
+ {isDevelopment && (
122
+ <div className="mb-6 rounded-lg border border-red-200 bg-red-50 p-4 text-left">
123
+ <p className="mb-1 text-xs font-semibold text-red-800">
124
+ Error (development only)
125
+ </p>
126
+ <p className="break-words font-mono text-sm text-red-700">
127
+ {error.message}
128
+ </p>
129
+ {error.digest && (
130
+ <p className="mt-2 font-mono text-xs text-red-700">
131
+ Digest: {error.digest}
132
+ </p>
133
+ )}
134
+ {error.stack && (
135
+ <details className="mt-2">
136
+ <summary className="cursor-pointer text-xs font-semibold text-red-800">
137
+ Stack trace
138
+ </summary>
139
+ <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded border border-red-200 bg-background p-3 text-xs">
140
+ {error.stack}
141
+ </pre>
142
+ </details>
143
+ )}
144
+ </div>
145
+ )}
146
+
147
+ {!isDevelopment && error.digest && (
148
+ <p className="mb-6 text-xs text-muted-foreground">
149
+ Reference:{' '}
150
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono">
151
+ {error.digest}
152
+ </code>
153
+ </p>
154
+ )}
155
+
156
+ <div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
157
+ <button
158
+ onClick={reset}
159
+ className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
160
+ >
161
+ {retryLabel}
162
+ </button>
163
+ {homeHref && (
164
+ <button
165
+ onClick={goHome}
166
+ className="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
167
+ >
168
+ {homeLabel}
169
+ </button>
170
+ )}
171
+ </div>
172
+ </div>
173
+ </div>
174
+ )
175
+ }
@@ -0,0 +1,6 @@
1
+ export { RouteErrorBoundary } from './RouteErrorBoundary'
2
+ export type { RouteErrorBoundaryProps, RouteError } from './RouteErrorBoundary'
3
+ export { GlobalError } from './GlobalError'
4
+ export type { GlobalErrorProps } from './GlobalError'
5
+ export { NotFound } from './NotFound'
6
+ export type { NotFoundProps } from './NotFound'
@@ -71,6 +71,11 @@ export * from './toast'
71
71
  // State components (ErrorState, EmptyState)
72
72
  export * from './states'
73
73
 
74
+ // Route-level error/404 surfaces (RouteErrorBoundary, GlobalError, NotFound)
75
+ // for app/error.tsx, app/global-error.tsx, app/not-found.tsx shells.
76
+ // Note: ApiErrorBoundary (subtree React error boundary) lives in ./ui.
77
+ export * from './error'
78
+
74
79
  // BaseDialog compound component
75
80
  export * from './dialog'
76
81