@talelk/newui-organisms 1.0.0 → 2.0.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 +3 -3
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
- package/src/organisms/AuthCard/AuthCard.tsx +41 -32
- package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
- package/src/organisms/DataState/DataState.test.tsx +45 -0
- package/src/organisms/DataState/DataState.tsx +111 -0
- package/src/organisms/DataState/index.ts +6 -0
- package/src/organisms/DataTable/DataTable.test.tsx +73 -1
- package/src/organisms/DataTable/DataTable.tsx +426 -148
- package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
- package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
- package/src/organisms/FilterBar/FilterBar.tsx +24 -5
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
- package/src/organisms/MobileNav/MobileNav.tsx +1 -1
- package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
- package/src/organisms/Section/Section.test.tsx +41 -0
- package/src/organisms/Section/Section.tsx +108 -0
- package/src/organisms/Section/index.ts +1 -0
- package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
- package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
- package/src/organisms/SelectionBar/index.ts +5 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
- package/src/organisms/Sidebar/Sidebar.tsx +17 -10
- package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
- package/src/organisms/StatGrid/StatGrid.tsx +98 -71
- package/src/organisms/VirtualList/VirtualList.tsx +56 -22
- package/src/organisms/index.ts +3 -0
- package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@talelk/newui-organisms",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Application regions, sidebars, tables and widgets built from atoms and molecules.",
|
|
6
6
|
"exports": {
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"src"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@talelk/newui-atoms": "1.0.
|
|
18
|
-
"@talelk/newui-molecules": "
|
|
17
|
+
"@talelk/newui-atoms": "1.0.1",
|
|
18
|
+
"@talelk/newui-molecules": "2.0.0",
|
|
19
19
|
"@tanstack/solid-table": "^9.3.1",
|
|
20
20
|
"@tanstack/solid-virtual": "^3.13.47"
|
|
21
21
|
},
|
|
@@ -69,6 +69,42 @@ describe('AppSidebar', () => {
|
|
|
69
69
|
expect(screen.getByRole('button', { name: /Rhea Kapoor/ })).toBeTruthy()
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
+
it('keeps a compact utility slot and an expand control in the rail', () => {
|
|
73
|
+
render(() => (
|
|
74
|
+
<AppSidebar
|
|
75
|
+
brand={<span>Acme</span>}
|
|
76
|
+
collapsed
|
|
77
|
+
utilities={<span>Preferences</span>}
|
|
78
|
+
utilitiesCollapsed={<button type="button">Theme</button>}
|
|
79
|
+
groups={[{ items: [] }]}
|
|
80
|
+
/>
|
|
81
|
+
))
|
|
82
|
+
expect(screen.queryByText('Preferences')).toBeNull()
|
|
83
|
+
expect(screen.getByRole('button', { name: 'Theme' })).toBeTruthy()
|
|
84
|
+
expect(screen.getByRole('button', { name: /Expand sidebar/ })).toBeTruthy()
|
|
85
|
+
expect(
|
|
86
|
+
screen.queryByRole('button', { name: /Collapse sidebar/ }),
|
|
87
|
+
).toBeNull()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('has no fold or compact utilities when it cannot collapse', () => {
|
|
91
|
+
render(() => (
|
|
92
|
+
<AppSidebar
|
|
93
|
+
brand={<span>Acme</span>}
|
|
94
|
+
collapsible={false}
|
|
95
|
+
utilities={<span>Preferences</span>}
|
|
96
|
+
utilitiesCollapsed={<button type="button">Theme</button>}
|
|
97
|
+
groups={[{ items: [] }]}
|
|
98
|
+
/>
|
|
99
|
+
))
|
|
100
|
+
expect(screen.getByText('Preferences')).toBeTruthy()
|
|
101
|
+
expect(screen.queryByRole('button', { name: 'Theme' })).toBeNull()
|
|
102
|
+
expect(screen.queryByRole('button', { name: /Expand sidebar/ })).toBeNull()
|
|
103
|
+
expect(
|
|
104
|
+
screen.queryByRole('button', { name: /Collapse sidebar/ }),
|
|
105
|
+
).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
72
108
|
it('collapses labels, preserves a compact tenant control and reports the state', async () => {
|
|
73
109
|
const onCollapsedChange = vi.fn()
|
|
74
110
|
sidebar(onCollapsedChange)
|
|
@@ -2,10 +2,10 @@ import { type IconProp, ScrollArea } from '@talelk/newui-atoms'
|
|
|
2
2
|
import { For, type JSX, Show } from 'solid-js'
|
|
3
3
|
import {
|
|
4
4
|
Sidebar,
|
|
5
|
-
SidebarCollapseToggle,
|
|
6
5
|
SidebarItem,
|
|
7
6
|
SidebarLabel,
|
|
8
7
|
type SidebarProps,
|
|
8
|
+
SidebarTrigger,
|
|
9
9
|
useSidebar,
|
|
10
10
|
} from '../Sidebar'
|
|
11
11
|
|
|
@@ -44,6 +44,10 @@ export interface AppSidebarProps {
|
|
|
44
44
|
groups?: AppSidebarGroup[]
|
|
45
45
|
/** Advanced navigation composition, still inside this single sidebar. */
|
|
46
46
|
children?: JSX.Element | (() => JSX.Element)
|
|
47
|
+
/** Global utilities rendered above Settings while the sidebar is expanded. */
|
|
48
|
+
utilities?: JSX.Element
|
|
49
|
+
/** Compact icon form of `utilities`, shown while the sidebar is collapsed. */
|
|
50
|
+
utilitiesCollapsed?: JSX.Element
|
|
47
51
|
settings?: JSX.Element
|
|
48
52
|
account?: JSX.Element
|
|
49
53
|
accountCollapsed?: JSX.Element
|
|
@@ -78,13 +82,29 @@ function AppSidebarIdentity(props: {
|
|
|
78
82
|
brandCollapsed?: JSX.Element
|
|
79
83
|
tenant?: JSX.Element
|
|
80
84
|
tenantCollapsed?: JSX.Element
|
|
85
|
+
collapsible: boolean
|
|
81
86
|
}): JSX.Element {
|
|
82
87
|
const { collapsed } = useSidebar()
|
|
83
88
|
return (
|
|
84
89
|
<>
|
|
85
|
-
<div
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
<div
|
|
91
|
+
data-slot="app-sidebar-brand"
|
|
92
|
+
class="flex h-12 shrink-0 items-center gap-1 ps-2 pe-1"
|
|
93
|
+
classList={{ 'justify-center px-1': collapsed() }}
|
|
94
|
+
>
|
|
95
|
+
<div
|
|
96
|
+
class="flex min-w-0 flex-1 items-center"
|
|
97
|
+
classList={{ 'justify-center': collapsed() }}
|
|
98
|
+
>
|
|
99
|
+
<Show when={collapsed()} fallback={props.brand}>
|
|
100
|
+
{props.brandCollapsed ?? props.brand}
|
|
101
|
+
</Show>
|
|
102
|
+
</div>
|
|
103
|
+
{/* The fold is chrome, not a destination: keeping it in the identity
|
|
104
|
+
row keeps it out of the menu in the expanded state; the rail
|
|
105
|
+
repeats the same control under the tenant. */}
|
|
106
|
+
<Show when={!collapsed() && props.collapsible}>
|
|
107
|
+
<SidebarTrigger />
|
|
88
108
|
</Show>
|
|
89
109
|
</div>
|
|
90
110
|
<Show when={props.tenant}>
|
|
@@ -92,30 +112,50 @@ function AppSidebarIdentity(props: {
|
|
|
92
112
|
data-slot="app-sidebar-tenant"
|
|
93
113
|
data-keep-drawer
|
|
94
114
|
class="shrink-0 px-2 pb-2"
|
|
115
|
+
classList={{ 'flex justify-center': collapsed() }}
|
|
95
116
|
>
|
|
96
117
|
<Show when={collapsed()} fallback={props.tenant}>
|
|
97
118
|
{props.tenantCollapsed}
|
|
98
119
|
</Show>
|
|
99
120
|
</div>
|
|
100
121
|
</Show>
|
|
122
|
+
<Show when={collapsed() && props.collapsible}>
|
|
123
|
+
<div
|
|
124
|
+
data-slot="app-sidebar-expand"
|
|
125
|
+
class="flex shrink-0 justify-center border-b border-outline-gray-1 px-2 pt-1 pb-2"
|
|
126
|
+
>
|
|
127
|
+
<SidebarTrigger />
|
|
128
|
+
</div>
|
|
129
|
+
</Show>
|
|
101
130
|
</>
|
|
102
131
|
)
|
|
103
132
|
}
|
|
104
133
|
|
|
105
134
|
function AppSidebarFooter(props: {
|
|
135
|
+
utilities?: JSX.Element
|
|
136
|
+
utilitiesCollapsed?: JSX.Element
|
|
106
137
|
settings?: JSX.Element
|
|
107
138
|
account?: JSX.Element
|
|
108
139
|
accountCollapsed?: JSX.Element
|
|
109
|
-
collapsible: boolean
|
|
110
140
|
}): JSX.Element {
|
|
111
141
|
const { collapsed } = useSidebar()
|
|
112
142
|
return (
|
|
113
143
|
<div
|
|
114
144
|
data-slot="app-sidebar-footer"
|
|
115
|
-
class="mt-auto flex shrink-0 flex-col gap-1 px-2 pt-2 pb-2"
|
|
145
|
+
class="mt-auto flex shrink-0 flex-col gap-1 border-t border-outline-gray-1 px-2 pt-2 pb-2"
|
|
116
146
|
>
|
|
117
|
-
<Show when={props.
|
|
118
|
-
<
|
|
147
|
+
<Show when={!collapsed() && props.utilities}>
|
|
148
|
+
<div data-slot="app-sidebar-utilities" class="pb-1">
|
|
149
|
+
{props.utilities}
|
|
150
|
+
</div>
|
|
151
|
+
</Show>
|
|
152
|
+
<Show when={collapsed() && props.utilitiesCollapsed}>
|
|
153
|
+
<div
|
|
154
|
+
data-slot="app-sidebar-utilities"
|
|
155
|
+
class="flex justify-center pb-0.5"
|
|
156
|
+
>
|
|
157
|
+
{props.utilitiesCollapsed}
|
|
158
|
+
</div>
|
|
119
159
|
</Show>
|
|
120
160
|
<Show when={props.settings}>{props.settings}</Show>
|
|
121
161
|
<Show
|
|
@@ -136,7 +176,6 @@ export function AppSidebar(props: AppSidebarProps): JSX.Element {
|
|
|
136
176
|
collapsed={props.collapsed}
|
|
137
177
|
onCollapsedChange={props.onCollapsedChange}
|
|
138
178
|
collapsible={props.collapsible}
|
|
139
|
-
shortcut={false}
|
|
140
179
|
ariaLabel={props.ariaLabel ?? 'Application navigation'}
|
|
141
180
|
class={props.class}
|
|
142
181
|
>
|
|
@@ -145,6 +184,7 @@ export function AppSidebar(props: AppSidebarProps): JSX.Element {
|
|
|
145
184
|
brandCollapsed={props.brandCollapsed}
|
|
146
185
|
tenant={props.tenant}
|
|
147
186
|
tenantCollapsed={props.tenantCollapsed}
|
|
187
|
+
collapsible={props.collapsible !== false}
|
|
148
188
|
/>
|
|
149
189
|
|
|
150
190
|
<Show when={props.search}>
|
|
@@ -174,14 +214,18 @@ export function AppSidebar(props: AppSidebarProps): JSX.Element {
|
|
|
174
214
|
{(item) => (
|
|
175
215
|
<SidebarItem
|
|
176
216
|
active={item.active}
|
|
177
|
-
label={
|
|
217
|
+
label={
|
|
218
|
+
item.count
|
|
219
|
+
? `${item.label}, ${item.count}`
|
|
220
|
+
: item.label
|
|
221
|
+
}
|
|
178
222
|
icon={item.icon}
|
|
179
223
|
route={item.route}
|
|
180
224
|
href={item.href}
|
|
181
225
|
onClick={() => item.onClick?.()}
|
|
182
226
|
suffix={
|
|
183
227
|
item.count ? (
|
|
184
|
-
<span class="rounded-full bg-surface-gray-2 px-1.5 py-px text-caption tabular-nums text-ink-gray-7">
|
|
228
|
+
<span class="rounded-full bg-surface-gray-2 px-1.5 py-px text-caption tabular-nums text-ink-gray-7 group-data-[active]:bg-surface-blue-2 group-data-[active]:text-ink-blue-8">
|
|
185
229
|
{item.count}
|
|
186
230
|
</span>
|
|
187
231
|
) : undefined
|
|
@@ -204,10 +248,11 @@ export function AppSidebar(props: AppSidebarProps): JSX.Element {
|
|
|
204
248
|
</ScrollArea.Root>
|
|
205
249
|
|
|
206
250
|
<AppSidebarFooter
|
|
251
|
+
utilities={props.utilities}
|
|
252
|
+
utilitiesCollapsed={props.utilitiesCollapsed}
|
|
207
253
|
settings={props.settings}
|
|
208
254
|
account={props.account}
|
|
209
255
|
accountCollapsed={props.accountCollapsed}
|
|
210
|
-
collapsible={props.collapsible !== false}
|
|
211
256
|
/>
|
|
212
257
|
</Sidebar>
|
|
213
258
|
)
|
|
@@ -13,6 +13,8 @@ export interface AuthCardProps {
|
|
|
13
13
|
description?: JSX.Element
|
|
14
14
|
/** The form itself — fields and the submit button. */
|
|
15
15
|
children: JSX.Element
|
|
16
|
+
/** Form-level failure summary, above the fields. */
|
|
17
|
+
error?: JSX.Element
|
|
16
18
|
/** Social sign-in buttons, in order. */
|
|
17
19
|
providers?: AuthProvider[]
|
|
18
20
|
/** The line under the form: "Don't have an account? Sign up". */
|
|
@@ -38,43 +40,50 @@ export function AuthCard(props: AuthCardProps): JSX.Element {
|
|
|
38
40
|
return (
|
|
39
41
|
<div
|
|
40
42
|
data-slot="auth-card"
|
|
41
|
-
class={`flex
|
|
43
|
+
class={`flex w-full max-w-sm flex-col gap-section-gap rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-card-pad-wide ${props.class ?? ''}`}
|
|
42
44
|
>
|
|
43
|
-
<div class="flex
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
</Show>
|
|
51
|
-
</div>
|
|
45
|
+
<div class="flex flex-col items-center gap-3 text-center">
|
|
46
|
+
<Show when={props.logo}>{props.logo}</Show>
|
|
47
|
+
<div class="flex flex-col gap-1">
|
|
48
|
+
<h1 class="text-heading text-ink-gray-9">{props.title}</h1>
|
|
49
|
+
<Show when={props.description}>
|
|
50
|
+
<p class="text-base text-ink-gray-7">{props.description}</p>
|
|
51
|
+
</Show>
|
|
52
52
|
</div>
|
|
53
|
+
</div>
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
<Show when={props.error}>
|
|
56
|
+
<div
|
|
57
|
+
role="alert"
|
|
58
|
+
class="rounded-control border border-outline-red-3 bg-surface-red-1 px-3 py-2 text-interface text-ink-red-8"
|
|
59
|
+
>
|
|
60
|
+
{props.error}
|
|
61
|
+
</div>
|
|
62
|
+
</Show>
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
<Divider label="or" />
|
|
58
|
-
<div class="flex flex-col gap-2">
|
|
59
|
-
<For each={props.providers}>
|
|
60
|
-
{(provider) => (
|
|
61
|
-
<Button
|
|
62
|
-
variant="outline"
|
|
63
|
-
theme="gray"
|
|
64
|
-
onClick={provider.onClick}
|
|
65
|
-
iconLeft={provider.icon}
|
|
66
|
-
>
|
|
67
|
-
{provider.label}
|
|
68
|
-
</Button>
|
|
69
|
-
)}
|
|
70
|
-
</For>
|
|
71
|
-
</div>
|
|
72
|
-
</Show>
|
|
64
|
+
<div class="flex flex-col gap-4">{props.children}</div>
|
|
73
65
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
66
|
+
<Show when={hasProviders()}>
|
|
67
|
+
<Divider label="or" />
|
|
68
|
+
<div class="flex flex-col gap-2">
|
|
69
|
+
<For each={props.providers}>
|
|
70
|
+
{(provider) => (
|
|
71
|
+
<Button
|
|
72
|
+
variant="outline"
|
|
73
|
+
theme="gray"
|
|
74
|
+
onClick={provider.onClick}
|
|
75
|
+
iconLeft={provider.icon}
|
|
76
|
+
>
|
|
77
|
+
{provider.label}
|
|
78
|
+
</Button>
|
|
79
|
+
)}
|
|
80
|
+
</For>
|
|
81
|
+
</div>
|
|
82
|
+
</Show>
|
|
83
|
+
|
|
84
|
+
<Show when={props.footer}>
|
|
85
|
+
<p class="text-center text-base text-ink-gray-7">{props.footer}</p>
|
|
86
|
+
</Show>
|
|
78
87
|
</div>
|
|
79
88
|
)
|
|
80
89
|
}
|
|
@@ -121,6 +121,8 @@ export interface CommandPaletteProps {
|
|
|
121
121
|
size?: 'sm' | 'md' | 'lg' | 'xl'
|
|
122
122
|
/** Rendered when nothing matches. */
|
|
123
123
|
empty?: JSX.Element
|
|
124
|
+
/** Replaces the list with a loading row while commands register. */
|
|
125
|
+
loading?: boolean
|
|
124
126
|
class?: string
|
|
125
127
|
children: JSX.Element
|
|
126
128
|
}
|
|
@@ -306,8 +308,17 @@ export function CommandPalette(props: CommandPaletteProps): JSX.Element {
|
|
|
306
308
|
so hiding the list until the registry is non-empty deadlocks —
|
|
307
309
|
no items, so no registry, so the empty state, forever.
|
|
308
310
|
*/}
|
|
309
|
-
{props.children}
|
|
310
|
-
<Show when={
|
|
311
|
+
<Show when={!props.loading}>{props.children}</Show>
|
|
312
|
+
<Show when={props.loading}>
|
|
313
|
+
<div
|
|
314
|
+
role="option"
|
|
315
|
+
aria-disabled="true"
|
|
316
|
+
class="px-2 py-6 text-center text-base text-ink-gray-6"
|
|
317
|
+
>
|
|
318
|
+
Loading commands…
|
|
319
|
+
</div>
|
|
320
|
+
</Show>
|
|
321
|
+
<Show when={!props.loading && isEmpty()}>
|
|
311
322
|
{/* A listbox may only contain options/groups: the empty
|
|
312
323
|
message is a disabled option, the one shape a screen
|
|
313
324
|
reader can address inside the list. */}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { DataState } from './DataState'
|
|
5
|
+
|
|
6
|
+
describe('DataState', () => {
|
|
7
|
+
it('renders ready children unchanged', () => {
|
|
8
|
+
render(() => (
|
|
9
|
+
<DataState status="ready">
|
|
10
|
+
<p>Rows</p>
|
|
11
|
+
</DataState>
|
|
12
|
+
))
|
|
13
|
+
expect(screen.getByText('Rows')).toBeTruthy()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('announces loading and renders the requested skeleton', () => {
|
|
17
|
+
const { container } = render(() => (
|
|
18
|
+
<DataState status="loading" skeleton="cards" />
|
|
19
|
+
))
|
|
20
|
+
expect(screen.getByRole('status', { name: 'Loading' })).toBeTruthy()
|
|
21
|
+
expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBe(4)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders an empty state action', () => {
|
|
25
|
+
render(() => (
|
|
26
|
+
<DataState
|
|
27
|
+
status="empty"
|
|
28
|
+
title="No expenses"
|
|
29
|
+
action={<button type="button">Clear filters</button>}
|
|
30
|
+
/>
|
|
31
|
+
))
|
|
32
|
+
expect(screen.getByText('No expenses')).toBeTruthy()
|
|
33
|
+
expect(screen.getByRole('button', { name: 'Clear filters' })).toBeTruthy()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('offers retry on error', async () => {
|
|
37
|
+
const onRetry = vi.fn()
|
|
38
|
+
render(() => (
|
|
39
|
+
<DataState status="error" error="The request failed" onRetry={onRetry} />
|
|
40
|
+
))
|
|
41
|
+
expect(screen.getByRole('alert')).toBeTruthy()
|
|
42
|
+
await userEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
|
43
|
+
expect(onRetry).toHaveBeenCalled()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Button, type IconProp, Skeleton } from '@talelk/newui-atoms'
|
|
2
|
+
import { EmptyState } from '@talelk/newui-molecules'
|
|
3
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
export type DataStatus = 'ready' | 'loading' | 'empty' | 'error'
|
|
6
|
+
export type DataSkeleton = 'rows' | 'cards' | 'chart'
|
|
7
|
+
|
|
8
|
+
export interface DataStateProps {
|
|
9
|
+
status: DataStatus
|
|
10
|
+
/** Skeleton shape while loading. @default 'rows' */
|
|
11
|
+
skeleton?: DataSkeleton
|
|
12
|
+
/** Skeleton row count. @default 5 */
|
|
13
|
+
rows?: number
|
|
14
|
+
title?: JSX.Element
|
|
15
|
+
description?: JSX.Element
|
|
16
|
+
action?: JSX.Element
|
|
17
|
+
/** Icon shown in the empty state. @default 'lucide-inbox' */
|
|
18
|
+
icon?: IconProp
|
|
19
|
+
error?: JSX.Element
|
|
20
|
+
onRetry?: () => void
|
|
21
|
+
children?: JSX.Element
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Loading(props: { skeleton: DataSkeleton; rows: number }): JSX.Element {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
data-slot="data-state-loading"
|
|
28
|
+
role="status"
|
|
29
|
+
aria-label="Loading"
|
|
30
|
+
class="p-card-pad"
|
|
31
|
+
>
|
|
32
|
+
<Show when={props.skeleton === 'rows'}>
|
|
33
|
+
<div class="flex flex-col gap-2">
|
|
34
|
+
<For each={Array.from({ length: props.rows })}>
|
|
35
|
+
{() => <Skeleton lines={1} class="h-9" />}
|
|
36
|
+
</For>
|
|
37
|
+
</div>
|
|
38
|
+
</Show>
|
|
39
|
+
<Show when={props.skeleton === 'cards'}>
|
|
40
|
+
<div class="grid grid-cols-1 gap-3 min-[360px]:grid-cols-2 xl:grid-cols-4">
|
|
41
|
+
<For each={Array.from({ length: 4 })}>
|
|
42
|
+
{() => <Skeleton lines={3} class="h-24" />}
|
|
43
|
+
</For>
|
|
44
|
+
</div>
|
|
45
|
+
</Show>
|
|
46
|
+
<Show when={props.skeleton === 'chart'}>
|
|
47
|
+
<Skeleton lines={4} class="h-64" />
|
|
48
|
+
</Show>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The one loading / empty / error contract for data organisms.
|
|
55
|
+
*
|
|
56
|
+
* It composes the existing `Skeleton` and `EmptyState` molecules, so every
|
|
57
|
+
* region uses the same tone and action placement. `ready` renders its children
|
|
58
|
+
* unchanged.
|
|
59
|
+
*/
|
|
60
|
+
export function DataState(props: DataStateProps): JSX.Element {
|
|
61
|
+
return (
|
|
62
|
+
<Show
|
|
63
|
+
when={props.status === 'ready'}
|
|
64
|
+
fallback={<StateFallback {...props} />}
|
|
65
|
+
>
|
|
66
|
+
{props.children}
|
|
67
|
+
</Show>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function StateFallback(props: DataStateProps): JSX.Element {
|
|
72
|
+
return (
|
|
73
|
+
<Show
|
|
74
|
+
when={props.status === 'loading'}
|
|
75
|
+
fallback={
|
|
76
|
+
<Show
|
|
77
|
+
when={props.status === 'error'}
|
|
78
|
+
fallback={
|
|
79
|
+
<div data-slot="data-state-empty">
|
|
80
|
+
<EmptyState
|
|
81
|
+
icon={props.icon ?? 'lucide-inbox'}
|
|
82
|
+
title={props.title ?? 'Nothing here yet'}
|
|
83
|
+
description={props.description}
|
|
84
|
+
action={props.action}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
<div data-slot="data-state-error" role="alert">
|
|
90
|
+
<EmptyState
|
|
91
|
+
icon="lucide-triangle-alert"
|
|
92
|
+
title={props.title ?? 'Could not load this'}
|
|
93
|
+
description={props.error ?? props.description}
|
|
94
|
+
action={
|
|
95
|
+
props.onRetry ? (
|
|
96
|
+
<Button variant="outline" onClick={props.onRetry}>
|
|
97
|
+
Try again
|
|
98
|
+
</Button>
|
|
99
|
+
) : (
|
|
100
|
+
props.action
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
</Show>
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
<Loading skeleton={props.skeleton ?? 'rows'} rows={props.rows ?? 5} />
|
|
109
|
+
</Show>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen, waitFor } from '@solidjs/testing-library'
|
|
1
|
+
import { render, screen, waitFor, within } from '@solidjs/testing-library'
|
|
2
2
|
import userEvent from '@testing-library/user-event'
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import { DataTable, type DataTableColumn } from './DataTable'
|
|
@@ -25,6 +25,16 @@ const COLUMNS: DataTableColumn<Row>[] = [
|
|
|
25
25
|
},
|
|
26
26
|
]
|
|
27
27
|
|
|
28
|
+
const MOBILE_COLUMNS: DataTableColumn<Row>[] = [
|
|
29
|
+
{ accessorKey: 'name', header: 'Name', meta: { mobile: 'title' } },
|
|
30
|
+
{
|
|
31
|
+
accessorKey: 'amount',
|
|
32
|
+
header: 'Amount',
|
|
33
|
+
cell: (context) => `$${context.getValue()}`,
|
|
34
|
+
meta: { align: 'end', mobile: 'amount' },
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
|
|
28
38
|
function table(props: Partial<Parameters<typeof DataTable<Row>>[0]> = {}) {
|
|
29
39
|
return render(() => (
|
|
30
40
|
<DataTable
|
|
@@ -82,6 +92,68 @@ describe('DataTable', () => {
|
|
|
82
92
|
})
|
|
83
93
|
})
|
|
84
94
|
|
|
95
|
+
it('renders mobile cards from column meta', () => {
|
|
96
|
+
table({ columns: MOBILE_COLUMNS })
|
|
97
|
+
const cards = document.querySelector('[data-slot="data-table-cards"]')
|
|
98
|
+
expect(cards).toBeTruthy()
|
|
99
|
+
expect(within(cards as HTMLElement).getByText('Alpha')).toBeTruthy()
|
|
100
|
+
expect(within(cards as HTMLElement).getByText('$30')).toBeTruthy()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('skips ineligible rows in selection and select-all', async () => {
|
|
104
|
+
const onRowsAction = vi.fn()
|
|
105
|
+
table({
|
|
106
|
+
columns: MOBILE_COLUMNS,
|
|
107
|
+
rowSelectable: (row) => row.id !== '2',
|
|
108
|
+
onRowsAction,
|
|
109
|
+
})
|
|
110
|
+
const tableRegion = screen.getByRole('region', { name: 'Rows table' })
|
|
111
|
+
expect(
|
|
112
|
+
within(tableRegion)
|
|
113
|
+
.getAllByRole('checkbox', { name: 'Select 2' })
|
|
114
|
+
.every((box) => box.hasAttribute('disabled')),
|
|
115
|
+
).toBe(true)
|
|
116
|
+
await userEvent.click(
|
|
117
|
+
within(tableRegion).getByRole('checkbox', {
|
|
118
|
+
name: 'Select all rows on this page',
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
const bulk = await screen.findByRole('button', {
|
|
122
|
+
name: /Update selected \(2\)/,
|
|
123
|
+
})
|
|
124
|
+
await userEvent.click(bulk)
|
|
125
|
+
expect(onRowsAction).toHaveBeenCalledWith(['1', '3'])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps a stable titled toolbar when an external filter bar owns search', () => {
|
|
129
|
+
render(() => (
|
|
130
|
+
<DataTable title="Rows" rows={ROWS} columns={COLUMNS} hideSearch />
|
|
131
|
+
))
|
|
132
|
+
const toolbar = document.querySelector('[data-slot="data-table-toolbar"]')
|
|
133
|
+
expect(toolbar?.textContent).toContain('Rows')
|
|
134
|
+
expect(
|
|
135
|
+
document.querySelector('[data-slot="data-table-columns"]'),
|
|
136
|
+
).toBeTruthy()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('renders the shared loading and error states', () => {
|
|
140
|
+
const loading = render(() => (
|
|
141
|
+
<DataTable title="Rows" rows={[]} columns={COLUMNS} loading />
|
|
142
|
+
))
|
|
143
|
+
expect(screen.getByRole('status', { name: 'Loading' })).toBeTruthy()
|
|
144
|
+
loading.unmount()
|
|
145
|
+
render(() => (
|
|
146
|
+
<DataTable title="Rows" rows={[]} columns={COLUMNS} error="Failed" />
|
|
147
|
+
))
|
|
148
|
+
expect(screen.getByRole('alert')).toBeTruthy()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('shows the mobile selection bar once a row is selected', async () => {
|
|
152
|
+
table({ onRowsAction: () => {} })
|
|
153
|
+
await userEvent.click(screen.getByRole('checkbox', { name: 'Select 1' }))
|
|
154
|
+
expect(document.querySelector('[data-slot="selection-bar"]')).toBeTruthy()
|
|
155
|
+
})
|
|
156
|
+
|
|
85
157
|
it('can be a read-only table with no selection column', () => {
|
|
86
158
|
table({ selectable: false })
|
|
87
159
|
expect(screen.queryByRole('checkbox', { name: /Select/ })).toBeNull()
|