@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.
Files changed (64) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +12 -2
  3. package/package.json +38 -3
  4. package/src/exports.test.ts +27 -0
  5. package/src/index.ts +1 -0
  6. package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
  7. package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
  8. package/src/organisms/AppSidebar/index.ts +1 -0
  9. package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
  10. package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
  11. package/src/organisms/AuthCard/AuthCard.tsx +89 -0
  12. package/src/organisms/AuthCard/index.ts +1 -0
  13. package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
  14. package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
  15. package/src/organisms/CommandPalette/index.ts +1 -0
  16. package/src/organisms/DataState/DataState.test.tsx +45 -0
  17. package/src/organisms/DataState/DataState.tsx +111 -0
  18. package/src/organisms/DataState/index.ts +6 -0
  19. package/src/organisms/DataTable/DataTable.test.tsx +161 -0
  20. package/src/organisms/DataTable/DataTable.tsx +612 -0
  21. package/src/organisms/DataTable/index.ts +1 -0
  22. package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
  23. package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
  24. package/src/organisms/DetailHeader/index.ts +1 -0
  25. package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
  26. package/src/organisms/FilterBar/FilterBar.tsx +251 -0
  27. package/src/organisms/FilterBar/index.ts +1 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
  29. package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
  30. package/src/organisms/KanbanBoard/drag.test.ts +448 -0
  31. package/src/organisms/KanbanBoard/drag.ts +407 -0
  32. package/src/organisms/KanbanBoard/index.ts +17 -0
  33. package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
  34. package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
  35. package/src/organisms/MobileNav/MobileNav.tsx +120 -0
  36. package/src/organisms/MobileNav/index.ts +6 -0
  37. package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
  38. package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
  39. package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
  40. package/src/organisms/PageToolbar/index.ts +1 -0
  41. package/src/organisms/Section/Section.test.tsx +41 -0
  42. package/src/organisms/Section/Section.tsx +108 -0
  43. package/src/organisms/Section/index.ts +1 -0
  44. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  45. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  46. package/src/organisms/SelectionBar/index.ts +5 -0
  47. package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
  48. package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
  49. package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
  50. package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
  51. package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
  52. package/src/organisms/SettingsDialog/index.ts +20 -0
  53. package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
  54. package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
  55. package/src/organisms/Sidebar/Sidebar.tsx +732 -0
  56. package/src/organisms/Sidebar/index.ts +22 -0
  57. package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
  58. package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
  59. package/src/organisms/StatGrid/StatGrid.tsx +151 -0
  60. package/src/organisms/StatGrid/index.ts +1 -0
  61. package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
  62. package/src/organisms/VirtualList/VirtualList.tsx +113 -0
  63. package/src/organisms/VirtualList/index.ts +1 -0
  64. 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
+ })