@talelk/newui-organisms 1.0.0 → 2.1.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 (50) hide show
  1. package/package.json +8 -4
  2. package/skills/newui-organisms/SKILL.md +91 -0
  3. package/src/exports.test.ts +3 -0
  4. package/src/organisms/AppSidebar/AppSidebar.a11y.test.tsx +75 -0
  5. package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
  6. package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
  7. package/src/organisms/AuthCard/AuthCard.tsx +41 -32
  8. package/src/organisms/CommandPalette/CommandPalette.a11y.test.tsx +58 -0
  9. package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
  10. package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
  11. package/src/organisms/DataState/DataState.browser.test.tsx +52 -0
  12. package/src/organisms/DataState/DataState.test.tsx +45 -0
  13. package/src/organisms/DataState/DataState.tsx +111 -0
  14. package/src/organisms/DataState/index.ts +6 -0
  15. package/src/organisms/DataTable/DataTable.a11y.test.tsx +71 -0
  16. package/src/organisms/DataTable/DataTable.features.test.tsx +191 -0
  17. package/src/organisms/DataTable/DataTable.test.tsx +73 -1
  18. package/src/organisms/DataTable/DataTable.tsx +609 -149
  19. package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
  20. package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
  21. package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
  22. package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
  23. package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
  24. package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
  25. package/src/organisms/FilterBar/FilterBar.tsx +24 -5
  26. package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
  27. package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
  29. package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
  30. package/src/organisms/MobileNav/MobileNav.tsx +1 -1
  31. package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
  32. package/src/organisms/Section/Section.a11y.test.tsx +49 -0
  33. package/src/organisms/Section/Section.browser.test.tsx +63 -0
  34. package/src/organisms/Section/Section.test.tsx +41 -0
  35. package/src/organisms/Section/Section.tsx +108 -0
  36. package/src/organisms/Section/index.ts +1 -0
  37. package/src/organisms/SelectionBar/SelectionBar.a11y.test.tsx +48 -0
  38. package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -0
  39. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  40. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  41. package/src/organisms/SelectionBar/index.ts +5 -0
  42. package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
  43. package/src/organisms/Sidebar/Sidebar.a11y.test.tsx +71 -0
  44. package/src/organisms/Sidebar/Sidebar.tsx +17 -10
  45. package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
  46. package/src/organisms/StatGrid/StatGrid.tsx +98 -71
  47. package/src/organisms/VirtualList/VirtualList.a11y.test.tsx +109 -0
  48. package/src/organisms/VirtualList/VirtualList.tsx +56 -22
  49. package/src/organisms/index.ts +3 -0
  50. 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": "1.0.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "Application regions, sidebars, tables and widgets built from atoms and molecules.",
6
6
  "exports": {
@@ -11,11 +11,12 @@
11
11
  }
12
12
  },
13
13
  "files": [
14
- "src"
14
+ "src",
15
+ "skills/newui-organisms"
15
16
  ],
16
17
  "dependencies": {
17
- "@talelk/newui-atoms": "1.0.0",
18
- "@talelk/newui-molecules": "1.0.0",
18
+ "@talelk/newui-atoms": "1.1.0",
19
+ "@talelk/newui-molecules": "2.1.0",
19
20
  "@tanstack/solid-table": "^9.3.1",
20
21
  "@tanstack/solid-virtual": "^3.13.47"
21
22
  },
@@ -35,6 +36,9 @@
35
36
  "url": "git+https://github.com/talelk/newui.git",
36
37
  "directory": "packages/organisms"
37
38
  },
39
+ "keywords": [
40
+ "tanstack-intent"
41
+ ],
38
42
  "scripts": {
39
43
  "typecheck": "tsc --noEmit -p tsconfig.json"
40
44
  }
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: newui-organisms
3
+ description: >
4
+ Use when composing page regions with @talelk/newui-organisms — DataTable
5
+ (search, sort, pagination, selection, column pinning, inline editing),
6
+ AppSidebar, Sidebar, FilterBar, StatGrid, KanbanBoard, VirtualList,
7
+ CommandPalette, DetailHeader, Section, SelectionBar, DataState, MobileNav,
8
+ AuthCard, SettingsDialog. Use when the user asks for a data table, app
9
+ sidebar, board, KPI grid or command palette in newui.
10
+ metadata:
11
+ type: core
12
+ domain: ui
13
+ library: '@talelk/newui-organisms'
14
+ library_version: '2.1.0'
15
+ sources:
16
+ - talelk/newui:packages/organisms/src/organisms/DataTable/DataTable.tsx
17
+ - talelk/newui:packages/organisms/src/organisms/AppSidebar/AppSidebar.tsx
18
+ - talelk/newui:packages/organisms/src/organisms/DataState/DataState.tsx
19
+ - talelk/newui:packages/organisms/src/organisms/SelectionBar/SelectionBar.tsx
20
+ - talelk/newui:spec/data-table.md
21
+ - talelk/newui:spec/sidebar.md
22
+ ---
23
+
24
+ # newui organisms
25
+
26
+ Page regions composed from the molecules and atoms. Read the `newui` skill
27
+ for the token vocabulary first.
28
+
29
+ ## Setup
30
+
31
+ ```tsx no-check
32
+ import { DataTable } from '@talelk/newui-organisms'
33
+ import { createSignal } from 'solid-js'
34
+
35
+ interface Row { id: string; title: string; amount: number }
36
+
37
+ export function Ledger(props: { rows: Row[] }) {
38
+ return (
39
+ <DataTable
40
+ title="Ledger"
41
+ rows={props.rows}
42
+ columns={[
43
+ { accessorKey: 'id', header: 'Ref' },
44
+ { accessorKey: 'title', header: 'Title' },
45
+ { accessorKey: 'amount', header: 'Amount', meta: { align: 'end' } },
46
+ ]}
47
+ actionLabel="Export selected"
48
+ onRowsAction={(ids) => exportRows(ids)}
49
+ />
50
+ )
51
+ }
52
+ ```
53
+
54
+ ## Core patterns
55
+
56
+ **Selection exists to feed the bulk action.** Checkboxes render only when
57
+ `onRowsAction` is set; a read table sets `selectable={false}` (or omits the
58
+ action).
59
+
60
+ **App-owned states: pinning, edits, data.** `pinnedStart`/`pinnedEnd` freeze
61
+ one column per scroll edge; pinning changes go through
62
+ `onPinnedColumnsChange` — the table never mutates it. With `editable` +
63
+ `onCellChange`, columns marked `meta.editable` render click-to-edit cells;
64
+ edits commit through the callback and the table shows whatever the next
65
+ `rows` carry. The table never mutates data itself.
66
+
67
+ **Loading, empty and error come from `DataState`, not ad-hoc markup.** Pass
68
+ `loading`, `error` + `onRetry` to `DataTable` (or wrap custom regions in
69
+ `DataState`); the shared surfaces keep the sweep's contracts.
70
+
71
+ **One sidebar model.** `AppShell` (templates) renders `AppSidebar` for you —
72
+ do not mount `Sidebar` beside it. Compose `Sidebar` directly only in pages
73
+ that are not `AppShell`-shaped.
74
+
75
+ ## Common mistakes
76
+
77
+ **Don't nest AppShell and Sidebar.** Two navigation regions fail the a11y
78
+ sweep's `no-duplicate-controls` rule.
79
+
80
+ **Don't format values in editable columns' raw fields.** `meta.editable`
81
+ edits the row's raw field; the formatted `cell` render returns after commit.
82
+ Editing a formatted string would corrupt it.
83
+
84
+ **Don't build sticky columns by hand.** Pinning already carries the sticky
85
+ chrome, divider and hover/selected tint; a hand-rolled `sticky` cell will
86
+ miss the row tint and fail the visual contract.
87
+
88
+ ## Completion
89
+
90
+ `pnpm verify:affected` covers the organisms suites; `spec/data-table.md` and
91
+ `spec/sidebar.md` are the contracts for the two largest regions.
@@ -5,6 +5,7 @@ const PUBLIC = [
5
5
  'AppSidebar',
6
6
  'AuthCard',
7
7
  'CommandPalette',
8
+ 'DataState',
8
9
  'DataTable',
9
10
  'DetailHeader',
10
11
  'FilterBar',
@@ -12,6 +13,8 @@ const PUBLIC = [
12
13
  'MobileNav',
13
14
  'PageToolbar',
14
15
  'SettingsDialog',
16
+ 'Section',
17
+ 'SelectionBar',
15
18
  'Sidebar',
16
19
  'StatGrid',
17
20
  'VirtualList',
@@ -0,0 +1,75 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { SidebarItem } from '../Sidebar'
5
+ import { AppSidebar } from './AppSidebar'
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
+ function workspace(props: { collapsed?: boolean } = {}) {
23
+ return (
24
+ <AppSidebar
25
+ brand={<span>Acme workspace</span>}
26
+ brandCollapsed={
27
+ <span>
28
+ <span class="sr-only">Acme home</span>A
29
+ </span>
30
+ }
31
+ tenant={<button type="button">Acme · Production</button>}
32
+ tenantCollapsed={
33
+ <button type="button" aria-label="Switch tenant">
34
+ A
35
+ </button>
36
+ }
37
+ collapsed={props.collapsed}
38
+ groups={[
39
+ {
40
+ label: 'Workspace',
41
+ items: [
42
+ { label: 'Projects', icon: 'lucide-folder-kanban', active: true },
43
+ { label: 'Customers', icon: 'lucide-users', count: 4 },
44
+ ],
45
+ },
46
+ {
47
+ label: 'Reports',
48
+ items: [{ label: 'Revenue', icon: 'lucide-chart-no-axes-combined' }],
49
+ },
50
+ ]}
51
+ settings={
52
+ <SidebarItem label="Settings" icon="lucide-settings">
53
+ Settings
54
+ </SidebarItem>
55
+ }
56
+ account={
57
+ <SidebarItem label="Rhea Kapoor" icon="lucide-user-round">
58
+ Rhea Kapoor
59
+ </SidebarItem>
60
+ }
61
+ />
62
+ )
63
+ }
64
+
65
+ describe('AppSidebar accessibility', () => {
66
+ it('has no axe violations for the expanded navigation', async () => {
67
+ const { container } = render(() => workspace())
68
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
69
+ })
70
+
71
+ it('has no axe violations for the collapsed rail', async () => {
72
+ const { container } = render(() => workspace({ collapsed: true }))
73
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
74
+ })
75
+ })
@@ -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 data-slot="app-sidebar-brand" class="shrink-0 px-2 pt-3 pb-2">
86
- <Show when={collapsed()} fallback={props.brand}>
87
- {props.brandCollapsed ?? props.brand}
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.collapsible}>
118
- <SidebarCollapseToggle />
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={item.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 min-h-screen items-center justify-center bg-surface-base px-4 py-12 ${props.class ?? ''}`}
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 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 shadow-sm">
44
- <div class="flex flex-col items-center gap-3 text-center">
45
- <Show when={props.logo}>{props.logo}</Show>
46
- <div class="flex flex-col gap-1">
47
- <h1 class="text-metric text-ink-gray-9">{props.title}</h1>
48
- <Show when={props.description}>
49
- <p class="text-base text-ink-gray-7">{props.description}</p>
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
- <div class="flex flex-col gap-4">{props.children}</div>
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
- <Show when={hasProviders()}>
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
- <Show when={props.footer}>
75
- <p class="text-center text-base text-ink-gray-7">{props.footer}</p>
76
- </Show>
77
- </div>
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
  }
@@ -0,0 +1,58 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { CommandGroup, CommandItem, CommandPalette } from './CommandPalette'
5
+
6
+ const AXE_OPTIONS = {
7
+ rules: {
8
+ 'color-contrast': { enabled: false },
9
+ region: { enabled: false },
10
+ },
11
+ }
12
+
13
+ function summarize(results: AxeResults) {
14
+ return results.violations.map((violation) => ({
15
+ id: violation.id,
16
+ impact: violation.impact,
17
+ targets: violation.nodes.map((node) => node.target),
18
+ }))
19
+ }
20
+
21
+ describe('CommandPalette accessibility', () => {
22
+ it('has no axe violations for an open palette of grouped commands', async () => {
23
+ // The dialog portals to the body, so the audit runs there — the same
24
+ // shape the SettingsDialog a11y test uses.
25
+ render(() => (
26
+ <CommandPalette defaultOpen label="Commands">
27
+ <CommandGroup label="Pages">
28
+ <CommandItem value="intro" icon="lucide-house">
29
+ Introduction
30
+ </CommandItem>
31
+ <CommandItem value="blocks" keywords="patterns sections">
32
+ Blocks
33
+ </CommandItem>
34
+ </CommandGroup>
35
+ <CommandGroup label="Actions">
36
+ <CommandItem value="theme" shortcut="⌘B">
37
+ Toggle theme
38
+ </CommandItem>
39
+ <CommandItem value="soon" disabled>
40
+ Coming soon
41
+ </CommandItem>
42
+ </CommandGroup>
43
+ </CommandPalette>
44
+ ))
45
+ expect(summarize(await axe.run(document.body, AXE_OPTIONS))).toEqual([])
46
+ })
47
+
48
+ it('has no axe violations while commands are loading', async () => {
49
+ render(() => (
50
+ <CommandPalette defaultOpen label="Commands" loading>
51
+ <CommandGroup label="Pages">
52
+ <CommandItem value="intro">Introduction</CommandItem>
53
+ </CommandGroup>
54
+ </CommandPalette>
55
+ ))
56
+ expect(summarize(await axe.run(document.body, AXE_OPTIONS))).toEqual([])
57
+ })
58
+ })
@@ -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={isEmpty()}>
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,59 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { DataState } from './DataState'
5
+
6
+ const AXE_OPTIONS = {
7
+ rules: {
8
+ 'color-contrast': { enabled: false },
9
+ region: { enabled: false },
10
+ },
11
+ }
12
+
13
+ function summarize(results: AxeResults) {
14
+ return results.violations.map((violation) => ({
15
+ id: violation.id,
16
+ impact: violation.impact,
17
+ targets: violation.nodes.map((node) => node.target),
18
+ }))
19
+ }
20
+
21
+ describe('DataState accessibility', () => {
22
+ it('has no axe violations while loading', async () => {
23
+ const { container } = render(() => (
24
+ <DataState status="loading" skeleton="cards" />
25
+ ))
26
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
27
+ })
28
+
29
+ it('has no axe violations for the empty state', async () => {
30
+ const { container } = render(() => (
31
+ <DataState
32
+ status="empty"
33
+ title="No expenses"
34
+ description="Try a different month."
35
+ action={<button type="button">Clear filters</button>}
36
+ />
37
+ ))
38
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
39
+ })
40
+
41
+ it('has no axe violations for the error state', async () => {
42
+ const { container } = render(() => (
43
+ <DataState status="error" error="The request failed" onRetry={() => {}} />
44
+ ))
45
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
46
+ })
47
+
48
+ it('has no axe violations when ready', async () => {
49
+ const { container } = render(() => (
50
+ <DataState status="ready">
51
+ <ul>
52
+ <li>Alpha</li>
53
+ <li>Beta</li>
54
+ </ul>
55
+ </DataState>
56
+ ))
57
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
58
+ })
59
+ })