@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.
- package/package.json +8 -4
- package/skills/newui-organisms/SKILL.md +91 -0
- package/src/exports.test.ts +3 -0
- package/src/organisms/AppSidebar/AppSidebar.a11y.test.tsx +75 -0
- 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.a11y.test.tsx +58 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
- package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
- package/src/organisms/DataState/DataState.browser.test.tsx +52 -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.a11y.test.tsx +71 -0
- package/src/organisms/DataTable/DataTable.features.test.tsx +191 -0
- package/src/organisms/DataTable/DataTable.test.tsx +73 -1
- package/src/organisms/DataTable/DataTable.tsx +609 -149
- package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
- package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
- package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
- package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
- package/src/organisms/FilterBar/FilterBar.tsx +24 -5
- package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
- package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
- package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
- package/src/organisms/MobileNav/MobileNav.tsx +1 -1
- package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
- package/src/organisms/Section/Section.a11y.test.tsx +49 -0
- package/src/organisms/Section/Section.browser.test.tsx +63 -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.a11y.test.tsx +48 -0
- package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -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.a11y.test.tsx +71 -0
- 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.a11y.test.tsx +109 -0
- 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": "1.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.
|
|
18
|
-
"@talelk/newui-molecules": "1.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.
|
package/src/exports.test.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -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={
|
|
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
|
+
})
|