@talelk/newui-organisms 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +12 -2
- package/package.json +38 -3
- package/src/exports.test.ts +27 -0
- package/src/index.ts +1 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
- package/src/organisms/AppSidebar/index.ts +1 -0
- package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
- package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
- package/src/organisms/AuthCard/AuthCard.tsx +89 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
- package/src/organisms/CommandPalette/index.ts +1 -0
- 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 +161 -0
- package/src/organisms/DataTable/DataTable.tsx +612 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
- package/src/organisms/FilterBar/FilterBar.tsx +251 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
- package/src/organisms/KanbanBoard/drag.test.ts +448 -0
- package/src/organisms/KanbanBoard/drag.ts +407 -0
- package/src/organisms/KanbanBoard/index.ts +17 -0
- package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
- package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
- package/src/organisms/MobileNav/MobileNav.tsx +120 -0
- package/src/organisms/MobileNav/index.ts +6 -0
- package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
- package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
- package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
- package/src/organisms/PageToolbar/index.ts +1 -0
- 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.a11y.test.tsx +58 -0
- package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
- package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
- package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
- package/src/organisms/SettingsDialog/index.ts +20 -0
- package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
- package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
- package/src/organisms/Sidebar/Sidebar.tsx +732 -0
- package/src/organisms/Sidebar/index.ts +22 -0
- package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
- package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
- package/src/organisms/StatGrid/StatGrid.tsx +151 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +113 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- package/src/organisms/index.ts +16 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { type IconProp, ScrollArea } from '@talelk/newui-atoms'
|
|
2
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
3
|
+
import {
|
|
4
|
+
Sidebar,
|
|
5
|
+
SidebarItem,
|
|
6
|
+
SidebarLabel,
|
|
7
|
+
type SidebarProps,
|
|
8
|
+
SidebarTrigger,
|
|
9
|
+
useSidebar,
|
|
10
|
+
} from '../Sidebar'
|
|
11
|
+
|
|
12
|
+
export interface AppSidebarItem {
|
|
13
|
+
label: string
|
|
14
|
+
icon?: IconProp
|
|
15
|
+
count?: number
|
|
16
|
+
active?: boolean
|
|
17
|
+
route?: string
|
|
18
|
+
href?: string
|
|
19
|
+
onClick?: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AppSidebarGroup {
|
|
23
|
+
label?: JSX.Element
|
|
24
|
+
action?: JSX.Element
|
|
25
|
+
items: AppSidebarItem[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The one navigation organism an application shell needs: identity and tenant
|
|
30
|
+
* at the top, one grouped menu, then utilities and the account at the bottom.
|
|
31
|
+
*
|
|
32
|
+
* The shell controls `collapsed`; every item, including the two bottom slots,
|
|
33
|
+
* stays in the same sidebar context and gets the icon tooltip behavior from
|
|
34
|
+
* `SidebarItem`. `brandCollapsed` and `tenantCollapsed` let the app choose a
|
|
35
|
+
* compact mark; if absent, the expanded slot itself is retained.
|
|
36
|
+
*/
|
|
37
|
+
export interface AppSidebarProps {
|
|
38
|
+
brand: JSX.Element
|
|
39
|
+
brandCollapsed?: JSX.Element
|
|
40
|
+
tenant?: JSX.Element
|
|
41
|
+
tenantCollapsed?: JSX.Element
|
|
42
|
+
/** Optional fixed control below the tenant and above the scrolling groups. */
|
|
43
|
+
search?: JSX.Element
|
|
44
|
+
groups?: AppSidebarGroup[]
|
|
45
|
+
/** Advanced navigation composition, still inside this single sidebar. */
|
|
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
|
|
51
|
+
settings?: JSX.Element
|
|
52
|
+
account?: JSX.Element
|
|
53
|
+
accountCollapsed?: JSX.Element
|
|
54
|
+
width?: SidebarProps['width']
|
|
55
|
+
collapsedWidth?: SidebarProps['collapsedWidth']
|
|
56
|
+
collapsed?: SidebarProps['collapsed']
|
|
57
|
+
onCollapsedChange?: SidebarProps['onCollapsedChange']
|
|
58
|
+
collapsible?: SidebarProps['collapsible']
|
|
59
|
+
ariaLabel?: string
|
|
60
|
+
class?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Calls a render-function navigation slot *inside* the Sidebar provider.
|
|
65
|
+
*
|
|
66
|
+
* A JSX element created by the caller has already run its component functions
|
|
67
|
+
* in the caller's context, so a custom menu that reads `useSidebar()` would
|
|
68
|
+
* capture the default expanded value and never react to the shell collapse.
|
|
69
|
+
* The function form defers evaluation until this component renders under the
|
|
70
|
+
* provider.
|
|
71
|
+
*/
|
|
72
|
+
function AppSidebarContent(props: {
|
|
73
|
+
content?: JSX.Element | (() => JSX.Element)
|
|
74
|
+
}): JSX.Element {
|
|
75
|
+
return (
|
|
76
|
+
<>{typeof props.content === 'function' ? props.content() : props.content}</>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function AppSidebarIdentity(props: {
|
|
81
|
+
brand: JSX.Element
|
|
82
|
+
brandCollapsed?: JSX.Element
|
|
83
|
+
tenant?: JSX.Element
|
|
84
|
+
tenantCollapsed?: JSX.Element
|
|
85
|
+
collapsible: boolean
|
|
86
|
+
}): JSX.Element {
|
|
87
|
+
const { collapsed } = useSidebar()
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
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 />
|
|
108
|
+
</Show>
|
|
109
|
+
</div>
|
|
110
|
+
<Show when={props.tenant}>
|
|
111
|
+
<div
|
|
112
|
+
data-slot="app-sidebar-tenant"
|
|
113
|
+
data-keep-drawer
|
|
114
|
+
class="shrink-0 px-2 pb-2"
|
|
115
|
+
classList={{ 'flex justify-center': collapsed() }}
|
|
116
|
+
>
|
|
117
|
+
<Show when={collapsed()} fallback={props.tenant}>
|
|
118
|
+
{props.tenantCollapsed}
|
|
119
|
+
</Show>
|
|
120
|
+
</div>
|
|
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>
|
|
130
|
+
</>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function AppSidebarFooter(props: {
|
|
135
|
+
utilities?: JSX.Element
|
|
136
|
+
utilitiesCollapsed?: JSX.Element
|
|
137
|
+
settings?: JSX.Element
|
|
138
|
+
account?: JSX.Element
|
|
139
|
+
accountCollapsed?: JSX.Element
|
|
140
|
+
}): JSX.Element {
|
|
141
|
+
const { collapsed } = useSidebar()
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
data-slot="app-sidebar-footer"
|
|
145
|
+
class="mt-auto flex shrink-0 flex-col gap-1 border-t border-outline-gray-1 px-2 pt-2 pb-2"
|
|
146
|
+
>
|
|
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>
|
|
159
|
+
</Show>
|
|
160
|
+
<Show when={props.settings}>{props.settings}</Show>
|
|
161
|
+
<Show
|
|
162
|
+
when={collapsed() && props.accountCollapsed}
|
|
163
|
+
fallback={props.account}
|
|
164
|
+
>
|
|
165
|
+
{props.accountCollapsed}
|
|
166
|
+
</Show>
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function AppSidebar(props: AppSidebarProps): JSX.Element {
|
|
172
|
+
return (
|
|
173
|
+
<Sidebar
|
|
174
|
+
width={props.width ?? '16rem'}
|
|
175
|
+
collapsedWidth={props.collapsedWidth ?? '3.5rem'}
|
|
176
|
+
collapsed={props.collapsed}
|
|
177
|
+
onCollapsedChange={props.onCollapsedChange}
|
|
178
|
+
collapsible={props.collapsible}
|
|
179
|
+
ariaLabel={props.ariaLabel ?? 'Application navigation'}
|
|
180
|
+
class={props.class}
|
|
181
|
+
>
|
|
182
|
+
<AppSidebarIdentity
|
|
183
|
+
brand={props.brand}
|
|
184
|
+
brandCollapsed={props.brandCollapsed}
|
|
185
|
+
tenant={props.tenant}
|
|
186
|
+
tenantCollapsed={props.tenantCollapsed}
|
|
187
|
+
collapsible={props.collapsible !== false}
|
|
188
|
+
/>
|
|
189
|
+
|
|
190
|
+
<Show when={props.search}>
|
|
191
|
+
<div data-slot="app-sidebar-search" class="shrink-0 px-2 pb-2">
|
|
192
|
+
{props.search}
|
|
193
|
+
</div>
|
|
194
|
+
</Show>
|
|
195
|
+
|
|
196
|
+
<ScrollArea.Root class="min-h-0 flex-1">
|
|
197
|
+
<ScrollArea.Viewport class="px-2 pt-1 pb-3">
|
|
198
|
+
<Show
|
|
199
|
+
when={props.children}
|
|
200
|
+
fallback={
|
|
201
|
+
<For each={props.groups ?? []}>
|
|
202
|
+
{(group, index) => (
|
|
203
|
+
<section
|
|
204
|
+
data-slot="app-sidebar-group"
|
|
205
|
+
class={index() === 0 ? undefined : 'mt-4'}
|
|
206
|
+
>
|
|
207
|
+
<Show when={group.label || group.action}>
|
|
208
|
+
<SidebarLabel divider action={group.action}>
|
|
209
|
+
{group.label}
|
|
210
|
+
</SidebarLabel>
|
|
211
|
+
</Show>
|
|
212
|
+
<div class="flex flex-col gap-0.5">
|
|
213
|
+
<For each={group.items}>
|
|
214
|
+
{(item) => (
|
|
215
|
+
<SidebarItem
|
|
216
|
+
active={item.active}
|
|
217
|
+
label={
|
|
218
|
+
item.count
|
|
219
|
+
? `${item.label}, ${item.count}`
|
|
220
|
+
: item.label
|
|
221
|
+
}
|
|
222
|
+
icon={item.icon}
|
|
223
|
+
route={item.route}
|
|
224
|
+
href={item.href}
|
|
225
|
+
onClick={() => item.onClick?.()}
|
|
226
|
+
suffix={
|
|
227
|
+
item.count ? (
|
|
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">
|
|
229
|
+
{item.count}
|
|
230
|
+
</span>
|
|
231
|
+
) : undefined
|
|
232
|
+
}
|
|
233
|
+
>
|
|
234
|
+
{item.label}
|
|
235
|
+
</SidebarItem>
|
|
236
|
+
)}
|
|
237
|
+
</For>
|
|
238
|
+
</div>
|
|
239
|
+
</section>
|
|
240
|
+
)}
|
|
241
|
+
</For>
|
|
242
|
+
}
|
|
243
|
+
>
|
|
244
|
+
<AppSidebarContent content={props.children} />
|
|
245
|
+
</Show>
|
|
246
|
+
</ScrollArea.Viewport>
|
|
247
|
+
<ScrollArea.Scrollbar />
|
|
248
|
+
</ScrollArea.Root>
|
|
249
|
+
|
|
250
|
+
<AppSidebarFooter
|
|
251
|
+
utilities={props.utilities}
|
|
252
|
+
utilitiesCollapsed={props.utilitiesCollapsed}
|
|
253
|
+
settings={props.settings}
|
|
254
|
+
account={props.account}
|
|
255
|
+
accountCollapsed={props.accountCollapsed}
|
|
256
|
+
/>
|
|
257
|
+
</Sidebar>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './AppSidebar'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { Button, TextInput } from '@talelk/newui-atoms'
|
|
3
|
+
import axe, { type AxeResults } from 'axe-core'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { AuthCard } from './AuthCard'
|
|
6
|
+
|
|
7
|
+
const AXE_OPTIONS = {
|
|
8
|
+
rules: {
|
|
9
|
+
'color-contrast': { enabled: false },
|
|
10
|
+
region: { enabled: false },
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function summarize(results: AxeResults) {
|
|
15
|
+
return results.violations.map((violation) => ({
|
|
16
|
+
id: violation.id,
|
|
17
|
+
impact: violation.impact,
|
|
18
|
+
targets: violation.nodes.map((node) => node.target),
|
|
19
|
+
}))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('AuthCard accessibility', () => {
|
|
23
|
+
it('has no axe violations for a sign-in card', async () => {
|
|
24
|
+
const { container } = render(() => (
|
|
25
|
+
<AuthCard
|
|
26
|
+
title="Sign in"
|
|
27
|
+
description="Welcome back. Enter your work account."
|
|
28
|
+
footer={<a href="/sign-up">Create an account</a>}
|
|
29
|
+
>
|
|
30
|
+
<TextInput label="Email" type="email" />
|
|
31
|
+
<Button variant="solid">Continue</Button>
|
|
32
|
+
</AuthCard>
|
|
33
|
+
))
|
|
34
|
+
expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { AuthCard } from './AuthCard'
|
|
4
|
+
|
|
5
|
+
describe('AuthCard', () => {
|
|
6
|
+
it('gives the page exactly one h1', () => {
|
|
7
|
+
render(() => (
|
|
8
|
+
<AuthCard title="Sign in" description="Welcome back">
|
|
9
|
+
<div>form</div>
|
|
10
|
+
</AuthCard>
|
|
11
|
+
))
|
|
12
|
+
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1)
|
|
13
|
+
expect(screen.getByText('Welcome back')).toBeTruthy()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('renders the form as given', () => {
|
|
17
|
+
render(() => (
|
|
18
|
+
<AuthCard title="Sign in">
|
|
19
|
+
<label for="email">Email</label>
|
|
20
|
+
<input id="email" />
|
|
21
|
+
</AuthCard>
|
|
22
|
+
))
|
|
23
|
+
expect(screen.getByLabelText('Email')).toBeTruthy()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('labels the divider only when both a form and providers are present', () => {
|
|
27
|
+
const without = render(() => (
|
|
28
|
+
<AuthCard title="Sign in">
|
|
29
|
+
<div>form</div>
|
|
30
|
+
</AuthCard>
|
|
31
|
+
))
|
|
32
|
+
expect(without.container.querySelector('[data-slot="divider"]')).toBeNull()
|
|
33
|
+
without.unmount()
|
|
34
|
+
|
|
35
|
+
render(() => (
|
|
36
|
+
<AuthCard title="Sign in" providers={[{ label: 'Continue with GitHub' }]}>
|
|
37
|
+
<div>form</div>
|
|
38
|
+
</AuthCard>
|
|
39
|
+
))
|
|
40
|
+
expect(
|
|
41
|
+
document.querySelectorAll('[data-slot="divider"]').length,
|
|
42
|
+
).toBeGreaterThan(0)
|
|
43
|
+
expect(
|
|
44
|
+
screen.getByRole('button', { name: /Continue with GitHub/ }),
|
|
45
|
+
).toBeTruthy()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders a footer when given one', () => {
|
|
49
|
+
render(() => (
|
|
50
|
+
<AuthCard title="Sign in" footer="Don't have an account?">
|
|
51
|
+
<div>form</div>
|
|
52
|
+
</AuthCard>
|
|
53
|
+
))
|
|
54
|
+
expect(screen.getByText("Don't have an account?")).toBeTruthy()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Button, Divider } from '@talelk/newui-atoms'
|
|
2
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
3
|
+
|
|
4
|
+
export interface AuthProvider {
|
|
5
|
+
label: string
|
|
6
|
+
/** A `lucide-*` string or a component (P11). */
|
|
7
|
+
icon?: string
|
|
8
|
+
onClick?: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AuthCardProps {
|
|
12
|
+
title: JSX.Element
|
|
13
|
+
description?: JSX.Element
|
|
14
|
+
/** The form itself — fields and the submit button. */
|
|
15
|
+
children: JSX.Element
|
|
16
|
+
/** Form-level failure summary, above the fields. */
|
|
17
|
+
error?: JSX.Element
|
|
18
|
+
/** Social sign-in buttons, in order. */
|
|
19
|
+
providers?: AuthProvider[]
|
|
20
|
+
/** The line under the form: "Don't have an account? Sign up". */
|
|
21
|
+
footer?: JSX.Element
|
|
22
|
+
/** Brand mark above the title. */
|
|
23
|
+
logo?: JSX.Element
|
|
24
|
+
class?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The centred card a sign-in, sign-up or reset screen is.
|
|
29
|
+
*
|
|
30
|
+
* One block rather than three screens, because the three are the same layout
|
|
31
|
+
* with different fields — and because the details that make it look
|
|
32
|
+
* intentional are the ones that get skipped when it is written inline: a capped
|
|
33
|
+
* measure so the fields do not stretch on a wide display, the divider labelled
|
|
34
|
+
* "or" only when both a form and providers are present, and the title as an
|
|
35
|
+
* `h1` so the page has exactly one.
|
|
36
|
+
*/
|
|
37
|
+
export function AuthCard(props: AuthCardProps): JSX.Element {
|
|
38
|
+
const hasProviders = () => (props.providers?.length ?? 0) > 0
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
data-slot="auth-card"
|
|
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 ?? ''}`}
|
|
44
|
+
>
|
|
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
|
+
</div>
|
|
53
|
+
</div>
|
|
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>
|
|
63
|
+
|
|
64
|
+
<div class="flex flex-col gap-4">{props.children}</div>
|
|
65
|
+
|
|
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>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './AuthCard'
|
|
@@ -0,0 +1,196 @@
|
|
|
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 { CommandGroup, CommandItem, CommandPalette } from './CommandPalette'
|
|
5
|
+
|
|
6
|
+
const noop = () => {}
|
|
7
|
+
|
|
8
|
+
function palette(
|
|
9
|
+
props: {
|
|
10
|
+
onSelect?: (value: string) => void
|
|
11
|
+
shortcut?: boolean
|
|
12
|
+
defaultOpen?: boolean
|
|
13
|
+
} = {},
|
|
14
|
+
) {
|
|
15
|
+
return render(() => (
|
|
16
|
+
<CommandPalette
|
|
17
|
+
defaultOpen={props.defaultOpen ?? true}
|
|
18
|
+
shortcut={props.shortcut}
|
|
19
|
+
label="Commands"
|
|
20
|
+
>
|
|
21
|
+
<CommandGroup label="Pages">
|
|
22
|
+
<CommandItem
|
|
23
|
+
value="intro"
|
|
24
|
+
icon="lucide-house"
|
|
25
|
+
onSelect={props.onSelect}
|
|
26
|
+
>
|
|
27
|
+
Introduction
|
|
28
|
+
</CommandItem>
|
|
29
|
+
<CommandItem
|
|
30
|
+
value="blocks"
|
|
31
|
+
keywords="patterns sections"
|
|
32
|
+
onSelect={props.onSelect}
|
|
33
|
+
>
|
|
34
|
+
Blocks
|
|
35
|
+
</CommandItem>
|
|
36
|
+
</CommandGroup>
|
|
37
|
+
<CommandGroup label="Actions">
|
|
38
|
+
<CommandItem value="theme" shortcut="⌘B" onSelect={props.onSelect}>
|
|
39
|
+
Toggle theme
|
|
40
|
+
</CommandItem>
|
|
41
|
+
<CommandItem value="soon" disabled onSelect={props.onSelect}>
|
|
42
|
+
Coming soon
|
|
43
|
+
</CommandItem>
|
|
44
|
+
</CommandGroup>
|
|
45
|
+
</CommandPalette>
|
|
46
|
+
))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const field = () => screen.getByRole('combobox', { name: 'Commands' })
|
|
50
|
+
const options = () => screen.queryAllByRole('option')
|
|
51
|
+
const activeId = () => field().getAttribute('aria-activedescendant')
|
|
52
|
+
|
|
53
|
+
describe('CommandPalette', () => {
|
|
54
|
+
it('renders its groups and items, and focuses the field', () => {
|
|
55
|
+
palette()
|
|
56
|
+
expect(screen.getByRole('listbox', { name: 'Commands' })).toBeTruthy()
|
|
57
|
+
expect(screen.getByText('Pages')).toBeTruthy()
|
|
58
|
+
expect(screen.getByText('Actions')).toBeTruthy()
|
|
59
|
+
expect(options()).toHaveLength(4)
|
|
60
|
+
expect(document.activeElement).toBe(field())
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('highlights the first usable item, not the first item', () => {
|
|
64
|
+
palette()
|
|
65
|
+
// `soon` is last and disabled; the first item is the one highlighted.
|
|
66
|
+
expect(activeId()).toBe(options()[0]?.id)
|
|
67
|
+
expect(options()[0]?.getAttribute('aria-selected')).toBe('true')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('narrows the list as you type, across every group', async () => {
|
|
71
|
+
palette()
|
|
72
|
+
await userEvent.type(field(), 'toggle')
|
|
73
|
+
|
|
74
|
+
expect(options()).toHaveLength(1)
|
|
75
|
+
expect(options()[0]?.textContent).toContain('Toggle theme')
|
|
76
|
+
// While searching, the heading is noise: relevance is the order.
|
|
77
|
+
expect(screen.queryByText('Pages')).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('matches a keyword the label does not contain', async () => {
|
|
81
|
+
palette()
|
|
82
|
+
await userEvent.type(field(), 'patterns')
|
|
83
|
+
expect(screen.getByText('Blocks')).toBeTruthy()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('drops the heading while filtering and restores it when cleared', async () => {
|
|
87
|
+
palette()
|
|
88
|
+
await userEvent.type(field(), 'toggle')
|
|
89
|
+
expect(screen.queryByText('Actions')).toBeNull()
|
|
90
|
+
|
|
91
|
+
await userEvent.clear(field())
|
|
92
|
+
expect(screen.getByText('Actions')).toBeTruthy()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('says so when nothing matches', async () => {
|
|
96
|
+
palette()
|
|
97
|
+
await userEvent.type(field(), 'zzzz')
|
|
98
|
+
// The message is the list's only child, and it is a disabled option: a
|
|
99
|
+
// listbox may not contain bare text (axe: aria-required-children).
|
|
100
|
+
const remaining = options()
|
|
101
|
+
expect(remaining).toHaveLength(1)
|
|
102
|
+
expect(remaining[0]).toHaveAttribute('aria-disabled', 'true')
|
|
103
|
+
expect(screen.getByText('Nothing matches.')).toBeTruthy()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('moves the highlight with the arrows, stepping over disabled items', async () => {
|
|
107
|
+
palette()
|
|
108
|
+
const items = options()
|
|
109
|
+
|
|
110
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
111
|
+
expect(activeId()).toBe(items[1]?.id)
|
|
112
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
113
|
+
expect(activeId()).toBe(items[2]?.id)
|
|
114
|
+
// The disabled item is skipped rather than landed on.
|
|
115
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
116
|
+
expect(activeId()).toBe(items[0]?.id)
|
|
117
|
+
await userEvent.keyboard('{ArrowUp}')
|
|
118
|
+
expect(activeId()).toBe(items[2]?.id)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('selects the highlighted item on Enter and closes', async () => {
|
|
122
|
+
const onSelect = vi.fn()
|
|
123
|
+
palette({ onSelect })
|
|
124
|
+
|
|
125
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
126
|
+
await userEvent.keyboard('{Enter}')
|
|
127
|
+
|
|
128
|
+
expect(onSelect).toHaveBeenCalledWith('blocks')
|
|
129
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('selects on click, and re-points the highlight on hover', async () => {
|
|
133
|
+
const onSelect = vi.fn()
|
|
134
|
+
palette({ onSelect })
|
|
135
|
+
|
|
136
|
+
const items = options()
|
|
137
|
+
await userEvent.hover(items[2] as HTMLElement)
|
|
138
|
+
expect(activeId()).toBe(items[2]?.id)
|
|
139
|
+
await userEvent.click(items[2] as HTMLElement)
|
|
140
|
+
expect(onSelect).toHaveBeenCalledWith('theme')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('does nothing on a disabled item', async () => {
|
|
144
|
+
const onSelect = vi.fn()
|
|
145
|
+
palette({ onSelect })
|
|
146
|
+
await userEvent.click(screen.getByText('Coming soon'))
|
|
147
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('closes on Escape', async () => {
|
|
151
|
+
palette()
|
|
152
|
+
await userEvent.keyboard('{Escape}')
|
|
153
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('opens on ⌘K, and again after a close', async () => {
|
|
157
|
+
palette({ defaultOpen: false, onSelect: noop })
|
|
158
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
159
|
+
|
|
160
|
+
await userEvent.keyboard('{Meta>}k{/Meta}')
|
|
161
|
+
expect(screen.getByRole('listbox', { name: 'Commands' })).toBeTruthy()
|
|
162
|
+
|
|
163
|
+
await userEvent.keyboard('{Escape}')
|
|
164
|
+
await userEvent.keyboard('{Meta>}k{/Meta}')
|
|
165
|
+
expect(screen.getByRole('listbox', { name: 'Commands' })).toBeTruthy()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('folds case and separators, so a compound name is found however it is typed', async () => {
|
|
169
|
+
palette()
|
|
170
|
+
// `Introduction` is one word, `Blocks` two — the fold is what makes a
|
|
171
|
+
// hyphen or a space equivalent to the case boundary.
|
|
172
|
+
await userEvent.type(field(), 'intro-duction')
|
|
173
|
+
expect(options()).toHaveLength(1)
|
|
174
|
+
expect(options()[0]?.textContent).toContain('Introduction')
|
|
175
|
+
await userEvent.clear(field())
|
|
176
|
+
await userEvent.type(field(), 'TOGGLE THEME')
|
|
177
|
+
expect(options()).toHaveLength(1)
|
|
178
|
+
expect(options()[0]?.textContent).toContain('Toggle theme')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('starts a fresh search each time it opens', async () => {
|
|
182
|
+
palette()
|
|
183
|
+
await userEvent.type(field(), 'toggle')
|
|
184
|
+
await userEvent.keyboard('{Escape}')
|
|
185
|
+
await userEvent.keyboard('{Meta>}k{/Meta}')
|
|
186
|
+
|
|
187
|
+
expect((field() as HTMLInputElement).value).toBe('')
|
|
188
|
+
expect(options()).toHaveLength(4)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('leaves ⌘K to the app when the shortcut is off', async () => {
|
|
192
|
+
palette({ defaultOpen: false, shortcut: false })
|
|
193
|
+
await userEvent.keyboard('{Meta>}k{/Meta}')
|
|
194
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
195
|
+
})
|
|
196
|
+
})
|