@startsimpli/ui 0.1.0 → 0.1.3
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/dist/chunk-27YUQBOE.mjs +3954 -0
- package/dist/chunk-27YUQBOE.mjs.map +1 -0
- package/dist/chunk-G2AM3DBU.mjs +1026 -0
- package/dist/chunk-G2AM3DBU.mjs.map +1 -0
- package/dist/chunk-G4XBXCFH.mjs +63 -0
- package/dist/chunk-G4XBXCFH.mjs.map +1 -0
- package/dist/chunk-LZOMFHX3.mjs +35 -0
- package/dist/chunk-LZOMFHX3.mjs.map +1 -0
- package/dist/chunk-QYXFLOO7.mjs +210 -0
- package/dist/chunk-QYXFLOO7.mjs.map +1 -0
- package/dist/components/index.d.mts +472 -0
- package/dist/components/index.d.ts +472 -0
- package/dist/components/index.js +5149 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/index.mjs +6 -0
- package/dist/components/index.mjs.map +1 -0
- package/dist/components/unified-table/index.d.mts +725 -0
- package/dist/components/unified-table/index.d.ts +725 -0
- package/dist/components/unified-table/index.js +4000 -0
- package/dist/components/unified-table/index.js.map +1 -0
- package/dist/components/unified-table/index.mjs +5 -0
- package/dist/components/unified-table/index.mjs.map +1 -0
- package/dist/index.d.mts +26 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +5448 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +12 -0
- package/dist/index.mjs.map +1 -0
- package/dist/theme/index.d.mts +20 -0
- package/dist/theme/index.d.ts +20 -0
- package/dist/theme/index.js +245 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/index.mjs +9 -0
- package/dist/theme/index.mjs.map +1 -0
- package/dist/utils/index.d.mts +38 -0
- package/dist/utils/index.d.ts +38 -0
- package/dist/utils/index.js +72 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/index.mjs +4 -0
- package/dist/utils/index.mjs.map +1 -0
- package/package.json +62 -21
- package/src/__mocks__/next/navigation.js +18 -0
- package/src/components/__tests__/safe-html.test.tsx +45 -0
- package/src/components/__tests__/states.test.tsx +94 -0
- package/src/components/__tests__/status-badge.test.tsx +101 -0
- package/src/components/__tests__/toast.test.tsx +124 -0
- package/src/components/badge/StatusBadge.tsx +55 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/dialog/BaseDialog.tsx +184 -0
- package/src/components/dialog/index.ts +8 -0
- package/src/components/index.ts +25 -0
- package/src/components/loading/DashboardSkeleton.tsx +27 -0
- package/src/components/loading/TableSkeleton.tsx +63 -0
- package/src/components/loading/index.ts +4 -0
- package/src/components/safe-html.tsx +18 -0
- package/src/components/states/EmptyState.tsx +48 -0
- package/src/components/states/ErrorState.tsx +76 -0
- package/src/components/states/index.ts +4 -0
- package/src/components/toast/Toaster.tsx +72 -0
- package/src/components/toast/index.ts +5 -0
- package/src/components/toast/use-notify.ts +45 -0
- package/src/components/toast/use-toast.ts +150 -0
- package/src/components/ui/api-error-boundary.tsx +64 -0
- package/src/components/ui/feature-gate.tsx +87 -0
- package/src/components/ui/index.ts +4 -0
- package/src/components/ui/page-loader.tsx +31 -0
- package/src/components/ui/query-provider.tsx +30 -0
- package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +1 -1
- package/src/components/unified-table/hooks/useFilters.ts +1 -0
- package/src/components/unified-table/hooks/usePagination.ts +1 -0
- package/src/components/unified-table/hooks/useSelection.ts +2 -1
- package/src/components/unified-table/hooks/useTableKeyboard.ts +2 -1
- package/src/components/unified-table/hooks/useTablePreferences.ts +1 -0
- package/src/components/unified-table/hooks/useTableState.ts +1 -0
- package/src/components/unified-table/hooks/useTableURL.test.tsx +1 -1
- package/src/components/unified-table/index.ts +4 -0
- package/src/components/wizard/StepIndicator.tsx +60 -0
- package/src/components/wizard/index.ts +2 -0
- package/src/theme/tailwind.config.d.ts +3 -0
- package/tailwind.preset.js +87 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const mockReplace = jest.fn()
|
|
2
|
+
const mockPush = jest.fn()
|
|
3
|
+
const mockSearchParams = new URLSearchParams()
|
|
4
|
+
const mockPathname = '/test-path'
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
useSearchParams: jest.fn(() => mockSearchParams),
|
|
8
|
+
useRouter: jest.fn(() => ({
|
|
9
|
+
replace: mockReplace,
|
|
10
|
+
push: mockPush,
|
|
11
|
+
back: jest.fn(),
|
|
12
|
+
forward: jest.fn(),
|
|
13
|
+
prefetch: jest.fn(),
|
|
14
|
+
refresh: jest.fn(),
|
|
15
|
+
})),
|
|
16
|
+
usePathname: jest.fn(() => mockPathname),
|
|
17
|
+
useParams: jest.fn(() => ({})),
|
|
18
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { SafeHtml } from '../safe-html'
|
|
3
|
+
|
|
4
|
+
describe('SafeHtml', () => {
|
|
5
|
+
it('renders safe HTML content', () => {
|
|
6
|
+
render(<SafeHtml html="<p>Hello <strong>world</strong></p>" />)
|
|
7
|
+
expect(screen.getByText(/Hello/)).toBeInTheDocument()
|
|
8
|
+
expect(screen.getByText(/world/)).toBeInTheDocument()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('strips script tags (XSS protection)', () => {
|
|
12
|
+
const { container } = render(
|
|
13
|
+
<SafeHtml html='<p>Safe</p><script>alert("xss")</script>' />
|
|
14
|
+
)
|
|
15
|
+
expect(container.querySelector('script')).toBeNull()
|
|
16
|
+
expect(container.textContent).toContain('Safe')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('strips event handlers (XSS protection)', () => {
|
|
20
|
+
const { container } = render(
|
|
21
|
+
<SafeHtml html='<img src="x" onerror="alert(1)">' />
|
|
22
|
+
)
|
|
23
|
+
const img = container.querySelector('img')
|
|
24
|
+
expect(img?.getAttribute('onerror')).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('preserves className prop', () => {
|
|
28
|
+
const { container } = render(
|
|
29
|
+
<SafeHtml html="<p>Test</p>" className="prose" />
|
|
30
|
+
)
|
|
31
|
+
expect(container.firstChild).toHaveAttribute('class', 'prose')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders with custom tag via as prop', () => {
|
|
35
|
+
const { container } = render(
|
|
36
|
+
<SafeHtml html="<p>Test</p>" as="section" />
|
|
37
|
+
)
|
|
38
|
+
expect(container.querySelector('section')).not.toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('handles empty string', () => {
|
|
42
|
+
const { container } = render(<SafeHtml html="" />)
|
|
43
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { ErrorState } from '../states/ErrorState'
|
|
3
|
+
import { EmptyState } from '../states/EmptyState'
|
|
4
|
+
|
|
5
|
+
describe('ErrorState', () => {
|
|
6
|
+
it('renders title and message', () => {
|
|
7
|
+
render(<ErrorState message="Something broke" />)
|
|
8
|
+
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
|
9
|
+
expect(screen.getByText('Something broke')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('renders custom title', () => {
|
|
13
|
+
render(<ErrorState title="Custom Error" message="Details here" />)
|
|
14
|
+
expect(screen.getByText('Custom Error')).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('renders with role="alert" and aria-live', () => {
|
|
18
|
+
const { container } = render(<ErrorState message="Error occurred" />)
|
|
19
|
+
const alertEl = container.querySelector('[role="alert"]')
|
|
20
|
+
expect(alertEl).toBeInTheDocument()
|
|
21
|
+
expect(alertEl).toHaveAttribute('aria-live', 'assertive')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders retry button when onRetry is provided', () => {
|
|
25
|
+
const onRetry = jest.fn()
|
|
26
|
+
render(<ErrorState message="Failed" onRetry={onRetry} />)
|
|
27
|
+
|
|
28
|
+
const retryButton = screen.getByLabelText('Retry loading content')
|
|
29
|
+
expect(retryButton).toBeInTheDocument()
|
|
30
|
+
|
|
31
|
+
fireEvent.click(retryButton)
|
|
32
|
+
expect(onRetry).toHaveBeenCalledTimes(1)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does not render retry button without onRetry', () => {
|
|
36
|
+
render(<ErrorState message="Failed" />)
|
|
37
|
+
expect(screen.queryByText('Try Again')).not.toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('applies custom className', () => {
|
|
41
|
+
const { container } = render(
|
|
42
|
+
<ErrorState message="Error" className="custom-class" />
|
|
43
|
+
)
|
|
44
|
+
expect(container.firstChild).toHaveClass('custom-class')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('EmptyState', () => {
|
|
49
|
+
it('renders title', () => {
|
|
50
|
+
render(<EmptyState title="No items" />)
|
|
51
|
+
expect(screen.getByText('No items')).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders description when provided', () => {
|
|
55
|
+
render(
|
|
56
|
+
<EmptyState title="No items" description="Add some items to get started" />
|
|
57
|
+
)
|
|
58
|
+
expect(screen.getByText('Add some items to get started')).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('does not render description when not provided', () => {
|
|
62
|
+
const { container } = render(<EmptyState title="No items" />)
|
|
63
|
+
const paragraphs = container.querySelectorAll('p')
|
|
64
|
+
expect(paragraphs).toHaveLength(0)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('renders action button when action is provided', () => {
|
|
68
|
+
const onClick = jest.fn()
|
|
69
|
+
render(
|
|
70
|
+
<EmptyState
|
|
71
|
+
title="No items"
|
|
72
|
+
action={{ label: 'Add Item', onClick }}
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const button = screen.getByLabelText('Add Item')
|
|
77
|
+
expect(button).toBeInTheDocument()
|
|
78
|
+
|
|
79
|
+
fireEvent.click(button)
|
|
80
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('does not render action button without action', () => {
|
|
84
|
+
render(<EmptyState title="No items" />)
|
|
85
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('has role="status" and aria-live="polite"', () => {
|
|
89
|
+
const { container } = render(<EmptyState title="Empty" />)
|
|
90
|
+
const statusEl = container.querySelector('[role="status"]')
|
|
91
|
+
expect(statusEl).toBeInTheDocument()
|
|
92
|
+
expect(statusEl).toHaveAttribute('aria-live', 'polite')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { StatusBadge } from '../badge/StatusBadge'
|
|
3
|
+
|
|
4
|
+
type TestStatus = 'active' | 'inactive' | 'pending'
|
|
5
|
+
|
|
6
|
+
const testConfig: Record<TestStatus, { label: string; className: string }> = {
|
|
7
|
+
active: {
|
|
8
|
+
label: 'Active',
|
|
9
|
+
className: 'bg-green-100 text-green-700',
|
|
10
|
+
},
|
|
11
|
+
inactive: {
|
|
12
|
+
label: 'Inactive',
|
|
13
|
+
className: 'bg-gray-100 text-gray-700',
|
|
14
|
+
},
|
|
15
|
+
pending: {
|
|
16
|
+
label: 'Pending',
|
|
17
|
+
className: 'bg-yellow-100 text-yellow-700',
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('StatusBadge', () => {
|
|
22
|
+
it('renders the correct label for a given status', () => {
|
|
23
|
+
render(
|
|
24
|
+
<StatusBadge<TestStatus>
|
|
25
|
+
status="active"
|
|
26
|
+
config={testConfig}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
expect(screen.getByText('Active')).toBeInTheDocument()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('applies the correct className for a given status', () => {
|
|
33
|
+
render(
|
|
34
|
+
<StatusBadge<TestStatus>
|
|
35
|
+
status="inactive"
|
|
36
|
+
config={testConfig}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
const badge = screen.getByText('Inactive')
|
|
40
|
+
expect(badge).toHaveClass('bg-gray-100')
|
|
41
|
+
expect(badge).toHaveClass('text-gray-700')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('renders each status correctly', () => {
|
|
45
|
+
const { rerender } = render(
|
|
46
|
+
<StatusBadge<TestStatus> status="active" config={testConfig} />
|
|
47
|
+
)
|
|
48
|
+
expect(screen.getByText('Active')).toBeInTheDocument()
|
|
49
|
+
|
|
50
|
+
rerender(
|
|
51
|
+
<StatusBadge<TestStatus> status="pending" config={testConfig} />
|
|
52
|
+
)
|
|
53
|
+
expect(screen.getByText('Pending')).toBeInTheDocument()
|
|
54
|
+
expect(screen.getByText('Pending')).toHaveClass('bg-yellow-100')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders raw status string for unknown status', () => {
|
|
58
|
+
render(
|
|
59
|
+
<StatusBadge
|
|
60
|
+
status="unknown"
|
|
61
|
+
config={testConfig as Record<string, { label: string; className: string }>}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
expect(screen.getByText('unknown')).toBeInTheDocument()
|
|
65
|
+
expect(screen.getByText('unknown')).toHaveClass('bg-gray-100')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('applies sm size class', () => {
|
|
69
|
+
render(
|
|
70
|
+
<StatusBadge<TestStatus>
|
|
71
|
+
status="active"
|
|
72
|
+
config={testConfig}
|
|
73
|
+
size="sm"
|
|
74
|
+
/>
|
|
75
|
+
)
|
|
76
|
+
const badge = screen.getByText('Active')
|
|
77
|
+
expect(badge).toHaveClass('px-2')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('applies md size class by default', () => {
|
|
81
|
+
render(
|
|
82
|
+
<StatusBadge<TestStatus>
|
|
83
|
+
status="active"
|
|
84
|
+
config={testConfig}
|
|
85
|
+
/>
|
|
86
|
+
)
|
|
87
|
+
const badge = screen.getByText('Active')
|
|
88
|
+
expect(badge).toHaveClass('px-2.5')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('applies custom className', () => {
|
|
92
|
+
render(
|
|
93
|
+
<StatusBadge<TestStatus>
|
|
94
|
+
status="active"
|
|
95
|
+
config={testConfig}
|
|
96
|
+
className="custom-badge"
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
expect(screen.getByText('Active')).toHaveClass('custom-badge')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { useToast, clearAllToasts, reducer } from '../toast/use-toast'
|
|
3
|
+
import type { ToasterToast } from '../toast/use-toast'
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
clearAllToasts()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe('useToast', () => {
|
|
10
|
+
it('adds a toast', () => {
|
|
11
|
+
const { result } = renderHook(() => useToast())
|
|
12
|
+
|
|
13
|
+
act(() => {
|
|
14
|
+
result.current.toast({ title: 'Hello', description: 'World' })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
expect(result.current.toasts).toHaveLength(1)
|
|
18
|
+
expect(result.current.toasts[0].title).toBe('Hello')
|
|
19
|
+
expect(result.current.toasts[0].description).toBe('World')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('adds toast with variant', () => {
|
|
23
|
+
const { result } = renderHook(() => useToast())
|
|
24
|
+
|
|
25
|
+
act(() => {
|
|
26
|
+
result.current.toast({ title: 'Error', variant: 'destructive' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(result.current.toasts[0].variant).toBe('destructive')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('dismisses a specific toast', () => {
|
|
33
|
+
jest.useFakeTimers()
|
|
34
|
+
const { result } = renderHook(() => useToast())
|
|
35
|
+
|
|
36
|
+
let toastId: string
|
|
37
|
+
act(() => {
|
|
38
|
+
// Use duration: 0 to prevent auto-dismiss interference
|
|
39
|
+
const t = result.current.toast({ title: 'Dismiss me', duration: 0 })
|
|
40
|
+
toastId = t.id
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(result.current.toasts).toHaveLength(1)
|
|
44
|
+
|
|
45
|
+
act(() => {
|
|
46
|
+
result.current.dismiss(toastId)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Advance past the dismiss animation delay
|
|
50
|
+
act(() => {
|
|
51
|
+
jest.advanceTimersByTime(500)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
expect(result.current.toasts).toHaveLength(0)
|
|
55
|
+
jest.useRealTimers()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('clears all toasts', () => {
|
|
59
|
+
const { result } = renderHook(() => useToast())
|
|
60
|
+
|
|
61
|
+
act(() => {
|
|
62
|
+
result.current.toast({ title: 'One', duration: 0 })
|
|
63
|
+
result.current.toast({ title: 'Two', duration: 0 })
|
|
64
|
+
result.current.toast({ title: 'Three', duration: 0 })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(result.current.toasts.length).toBeGreaterThanOrEqual(1)
|
|
68
|
+
|
|
69
|
+
act(() => {
|
|
70
|
+
result.current.clear()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(result.current.toasts).toHaveLength(0)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('reducer', () => {
|
|
78
|
+
const makeToast = (id: string, title: string): ToasterToast => ({
|
|
79
|
+
id,
|
|
80
|
+
title,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('ADD_TOAST adds to front of list', () => {
|
|
84
|
+
const state = { toasts: [makeToast('1', 'First')] }
|
|
85
|
+
const result = reducer(state, {
|
|
86
|
+
type: 'ADD_TOAST',
|
|
87
|
+
toast: makeToast('2', 'Second'),
|
|
88
|
+
})
|
|
89
|
+
expect(result.toasts[0].title).toBe('Second')
|
|
90
|
+
expect(result.toasts).toHaveLength(2)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('UPDATE_TOAST updates matching toast', () => {
|
|
94
|
+
const state = { toasts: [makeToast('1', 'Original')] }
|
|
95
|
+
const result = reducer(state, {
|
|
96
|
+
type: 'UPDATE_TOAST',
|
|
97
|
+
toast: { id: '1', title: 'Updated' },
|
|
98
|
+
})
|
|
99
|
+
expect(result.toasts[0].title).toBe('Updated')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('REMOVE_TOAST with undefined removes all', () => {
|
|
103
|
+
const state = {
|
|
104
|
+
toasts: [makeToast('1', 'A'), makeToast('2', 'B')],
|
|
105
|
+
}
|
|
106
|
+
const result = reducer(state, {
|
|
107
|
+
type: 'REMOVE_TOAST',
|
|
108
|
+
toastId: undefined,
|
|
109
|
+
})
|
|
110
|
+
expect(result.toasts).toHaveLength(0)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('REMOVE_TOAST with id removes specific toast', () => {
|
|
114
|
+
const state = {
|
|
115
|
+
toasts: [makeToast('1', 'A'), makeToast('2', 'B')],
|
|
116
|
+
}
|
|
117
|
+
const result = reducer(state, {
|
|
118
|
+
type: 'REMOVE_TOAST',
|
|
119
|
+
toastId: '1',
|
|
120
|
+
})
|
|
121
|
+
expect(result.toasts).toHaveLength(1)
|
|
122
|
+
expect(result.toasts[0].id).toBe('2')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
|
|
4
|
+
export interface StatusBadgeConfig {
|
|
5
|
+
label: string
|
|
6
|
+
className: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StatusBadgeProps<T extends string> {
|
|
10
|
+
status: T
|
|
11
|
+
config: Record<T, StatusBadgeConfig>
|
|
12
|
+
size?: 'sm' | 'md'
|
|
13
|
+
className?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sizeClasses = {
|
|
17
|
+
sm: 'px-2 py-0.5 text-xs',
|
|
18
|
+
md: 'px-2.5 py-0.5 text-xs',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function StatusBadge<T extends string>({
|
|
22
|
+
status,
|
|
23
|
+
config,
|
|
24
|
+
size = 'md',
|
|
25
|
+
className,
|
|
26
|
+
}: StatusBadgeProps<T>) {
|
|
27
|
+
const entry = config[status]
|
|
28
|
+
|
|
29
|
+
if (!entry) {
|
|
30
|
+
return (
|
|
31
|
+
<span
|
|
32
|
+
className={cn(
|
|
33
|
+
'inline-flex items-center rounded-full font-medium bg-gray-100 text-gray-700 border border-gray-200',
|
|
34
|
+
sizeClasses[size],
|
|
35
|
+
className
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{status}
|
|
39
|
+
</span>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<span
|
|
45
|
+
className={cn(
|
|
46
|
+
'inline-flex items-center rounded-full font-medium',
|
|
47
|
+
sizeClasses[size],
|
|
48
|
+
entry.className,
|
|
49
|
+
className
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
{entry.label}
|
|
53
|
+
</span>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
|
|
7
|
+
interface DialogContextValue {
|
|
8
|
+
isLoading: boolean
|
|
9
|
+
setIsLoading: (loading: boolean) => void
|
|
10
|
+
canClose: boolean
|
|
11
|
+
setCanClose: (canClose: boolean) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DialogContext = React.createContext<DialogContextValue | null>(null)
|
|
15
|
+
|
|
16
|
+
function useDialogContext() {
|
|
17
|
+
const context = React.useContext(DialogContext)
|
|
18
|
+
if (!context) {
|
|
19
|
+
throw new Error('Dialog components must be used within BaseDialog')
|
|
20
|
+
}
|
|
21
|
+
return context
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BaseDialogProps {
|
|
25
|
+
open: boolean
|
|
26
|
+
onOpenChange: (open: boolean) => void
|
|
27
|
+
children: React.ReactNode
|
|
28
|
+
size?: 'sm' | 'md' | 'lg'
|
|
29
|
+
loading?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sizeClasses = {
|
|
33
|
+
sm: 'max-w-sm',
|
|
34
|
+
md: 'max-w-md',
|
|
35
|
+
lg: 'max-w-lg',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function BaseDialog({
|
|
39
|
+
open,
|
|
40
|
+
onOpenChange,
|
|
41
|
+
children,
|
|
42
|
+
size = 'md',
|
|
43
|
+
loading = false,
|
|
44
|
+
}: BaseDialogProps) {
|
|
45
|
+
const [isLoading, setIsLoading] = React.useState(loading)
|
|
46
|
+
const [canClose, setCanClose] = React.useState(!loading)
|
|
47
|
+
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
setIsLoading(loading)
|
|
50
|
+
setCanClose(!loading)
|
|
51
|
+
}, [loading])
|
|
52
|
+
|
|
53
|
+
const handleOpenChange = React.useCallback(
|
|
54
|
+
(newOpen: boolean) => {
|
|
55
|
+
if (!newOpen && !canClose) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
onOpenChange(newOpen)
|
|
59
|
+
},
|
|
60
|
+
[canClose, onOpenChange]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const contextValue = React.useMemo(
|
|
64
|
+
() => ({ isLoading, setIsLoading, canClose, setCanClose }),
|
|
65
|
+
[isLoading, canClose]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<DialogContext.Provider value={contextValue}>
|
|
70
|
+
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
|
|
71
|
+
<DialogPrimitive.Portal>
|
|
72
|
+
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
|
73
|
+
<DialogPrimitive.Content
|
|
74
|
+
className={cn(
|
|
75
|
+
'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
|
76
|
+
sizeClasses[size]
|
|
77
|
+
)}
|
|
78
|
+
onInteractOutside={(e) => {
|
|
79
|
+
if (!canClose) e.preventDefault()
|
|
80
|
+
}}
|
|
81
|
+
onEscapeKeyDown={(e) => {
|
|
82
|
+
if (!canClose) e.preventDefault()
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
</DialogPrimitive.Content>
|
|
87
|
+
</DialogPrimitive.Portal>
|
|
88
|
+
</DialogPrimitive.Root>
|
|
89
|
+
</DialogContext.Provider>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DialogHeaderProps {
|
|
94
|
+
children: React.ReactNode
|
|
95
|
+
className?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function DialogHeader({ children, className }: DialogHeaderProps) {
|
|
99
|
+
const { canClose } = useDialogContext()
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className={cn('flex items-start justify-between', className)}>
|
|
103
|
+
<div className="flex flex-col space-y-1.5">{children}</div>
|
|
104
|
+
<DialogPrimitive.Close
|
|
105
|
+
className={cn(
|
|
106
|
+
'rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none',
|
|
107
|
+
!canClose && 'pointer-events-none opacity-30'
|
|
108
|
+
)}
|
|
109
|
+
disabled={!canClose}
|
|
110
|
+
>
|
|
111
|
+
<svg
|
|
112
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
113
|
+
width="16"
|
|
114
|
+
height="16"
|
|
115
|
+
viewBox="0 0 24 24"
|
|
116
|
+
fill="none"
|
|
117
|
+
stroke="currentColor"
|
|
118
|
+
strokeWidth="2"
|
|
119
|
+
strokeLinecap="round"
|
|
120
|
+
strokeLinejoin="round"
|
|
121
|
+
>
|
|
122
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
123
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
124
|
+
</svg>
|
|
125
|
+
<span className="sr-only">Close</span>
|
|
126
|
+
</DialogPrimitive.Close>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface DialogTitleProps {
|
|
132
|
+
children: React.ReactNode
|
|
133
|
+
className?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function DialogTitle({ children, className }: DialogTitleProps) {
|
|
137
|
+
return (
|
|
138
|
+
<DialogPrimitive.Title
|
|
139
|
+
className={cn(
|
|
140
|
+
'text-lg font-semibold leading-none tracking-tight',
|
|
141
|
+
className
|
|
142
|
+
)}
|
|
143
|
+
>
|
|
144
|
+
{children}
|
|
145
|
+
</DialogPrimitive.Title>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface DialogBodyProps {
|
|
150
|
+
children: React.ReactNode
|
|
151
|
+
className?: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function DialogBody({ children, className }: DialogBodyProps) {
|
|
155
|
+
return (
|
|
156
|
+
<div className={cn('py-4 overflow-y-auto', className)}>{children}</div>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface DialogFooterProps {
|
|
161
|
+
children: React.ReactNode
|
|
162
|
+
className?: string
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function DialogFooter({ children, className }: DialogFooterProps) {
|
|
166
|
+
return (
|
|
167
|
+
<div
|
|
168
|
+
className={cn(
|
|
169
|
+
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
|
170
|
+
className
|
|
171
|
+
)}
|
|
172
|
+
>
|
|
173
|
+
{children}
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Compound component pattern
|
|
179
|
+
BaseDialog.Header = DialogHeader
|
|
180
|
+
BaseDialog.Title = DialogTitle
|
|
181
|
+
BaseDialog.Body = DialogBody
|
|
182
|
+
BaseDialog.Footer = DialogFooter
|
|
183
|
+
|
|
184
|
+
export { useDialogContext }
|
package/src/components/index.ts
CHANGED
|
@@ -6,6 +6,10 @@ export { UnifiedTable } from './unified-table'
|
|
|
6
6
|
export type { UnifiedTableProps } from './unified-table'
|
|
7
7
|
export * from './unified-table/types'
|
|
8
8
|
|
|
9
|
+
// Export StandardTableToolbar for app-level table toolbars
|
|
10
|
+
export { StandardTableToolbar } from './unified-table'
|
|
11
|
+
export type { StandardTableToolbarProps } from './unified-table'
|
|
12
|
+
|
|
9
13
|
// MobileView - Card re-exported under non-conflicting names
|
|
10
14
|
export { MobileView, Card as MobileCard, CardActions as MobileCardActions } from './unified-table'
|
|
11
15
|
export type {
|
|
@@ -48,3 +52,24 @@ export * from './unified-table/utils'
|
|
|
48
52
|
|
|
49
53
|
// Export Navigation
|
|
50
54
|
export * from './navigation/sidebar'
|
|
55
|
+
|
|
56
|
+
// HTML rendering with XSS protection
|
|
57
|
+
export { SafeHtml } from './safe-html'
|
|
58
|
+
|
|
59
|
+
// Toast system
|
|
60
|
+
export * from './toast'
|
|
61
|
+
|
|
62
|
+
// State components (ErrorState, EmptyState)
|
|
63
|
+
export * from './states'
|
|
64
|
+
|
|
65
|
+
// BaseDialog compound component
|
|
66
|
+
export * from './dialog'
|
|
67
|
+
|
|
68
|
+
// StatusBadge
|
|
69
|
+
export * from './badge'
|
|
70
|
+
|
|
71
|
+
// Loading skeletons
|
|
72
|
+
export * from './loading'
|
|
73
|
+
|
|
74
|
+
// Wizard / StepIndicator
|
|
75
|
+
export * from './wizard'
|