@talelk/newui-organisms 0.0.0-stage → 1.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 (56) 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 +100 -0
  7. package/src/organisms/AppSidebar/AppSidebar.tsx +214 -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 +80 -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 +457 -0
  15. package/src/organisms/CommandPalette/index.ts +1 -0
  16. package/src/organisms/DataTable/DataTable.test.tsx +89 -0
  17. package/src/organisms/DataTable/DataTable.tsx +334 -0
  18. package/src/organisms/DataTable/index.ts +1 -0
  19. package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
  20. package/src/organisms/DetailHeader/DetailHeader.tsx +87 -0
  21. package/src/organisms/DetailHeader/index.ts +1 -0
  22. package/src/organisms/FilterBar/FilterBar.test.tsx +151 -0
  23. package/src/organisms/FilterBar/FilterBar.tsx +232 -0
  24. package/src/organisms/FilterBar/index.ts +1 -0
  25. package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
  26. package/src/organisms/KanbanBoard/KanbanBoard.tsx +550 -0
  27. package/src/organisms/KanbanBoard/drag.test.ts +448 -0
  28. package/src/organisms/KanbanBoard/drag.ts +407 -0
  29. package/src/organisms/KanbanBoard/index.ts +17 -0
  30. package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
  31. package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
  32. package/src/organisms/MobileNav/MobileNav.tsx +120 -0
  33. package/src/organisms/MobileNav/index.ts +6 -0
  34. package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
  35. package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
  36. package/src/organisms/PageToolbar/PageToolbar.tsx +77 -0
  37. package/src/organisms/PageToolbar/index.ts +1 -0
  38. package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
  39. package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
  40. package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
  41. package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
  42. package/src/organisms/SettingsDialog/SettingsDialog.tsx +539 -0
  43. package/src/organisms/SettingsDialog/index.ts +20 -0
  44. package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
  45. package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
  46. package/src/organisms/Sidebar/Sidebar.tsx +725 -0
  47. package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
  48. package/src/organisms/Sidebar/index.ts +22 -0
  49. package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
  50. package/src/organisms/StatGrid/StatGrid.test.tsx +83 -0
  51. package/src/organisms/StatGrid/StatGrid.tsx +124 -0
  52. package/src/organisms/StatGrid/index.ts +1 -0
  53. package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
  54. package/src/organisms/VirtualList/VirtualList.tsx +79 -0
  55. package/src/organisms/VirtualList/index.ts +1 -0
  56. package/src/organisms/index.ts +13 -0
@@ -0,0 +1,35 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { PageToolbar } from './PageToolbar'
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('PageToolbar accessibility', () => {
22
+ it('has no axe violations with a menu and a primary action', async () => {
23
+ const { container } = render(() => (
24
+ <PageToolbar
25
+ title="Q4 invoices"
26
+ menu={[
27
+ { label: 'Rename', onClick: () => {} },
28
+ { label: 'Archive', onClick: () => {} },
29
+ ]}
30
+ action={{ label: 'New invoice', onClick: () => {} }}
31
+ />
32
+ ))
33
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
34
+ })
35
+ })
@@ -0,0 +1,42 @@
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 { PageToolbar } from './PageToolbar'
5
+
6
+ describe('PageToolbar', () => {
7
+ it('renders the title and the primary action', () => {
8
+ render(() => <PageToolbar title="Tickets" action={{ label: 'Add new' }} />)
9
+ expect(screen.getByText('Tickets')).toBeTruthy()
10
+ expect(screen.getByRole('button', { name: /Add new/ })).toBeTruthy()
11
+ })
12
+
13
+ it('calls the primary action', async () => {
14
+ const onClick = vi.fn()
15
+ render(() => (
16
+ <PageToolbar title="Tickets" action={{ label: 'Add new', onClick }} />
17
+ ))
18
+ await userEvent.click(screen.getByRole('button', { name: /Add new/ }))
19
+ expect(onClick).toHaveBeenCalled()
20
+ })
21
+
22
+ it('renders no title menu unless one is given', () => {
23
+ const { container } = render(() => <PageToolbar title="Tickets" />)
24
+ expect(container.querySelector('[data-slot="menu-trigger"]')).toBeNull()
25
+ })
26
+
27
+ it('opens the title menu and runs the chosen entry', async () => {
28
+ const onArchive = vi.fn()
29
+ render(() => (
30
+ <PageToolbar
31
+ title="Tickets"
32
+ menu={[{ label: 'Archive', onClick: onArchive }]}
33
+ />
34
+ ))
35
+
36
+ await userEvent.click(screen.getByRole('button', { name: 'Page actions' }))
37
+ await userEvent.click(
38
+ await screen.findByRole('menuitem', { name: /Archive/ }),
39
+ )
40
+ expect(onArchive).toHaveBeenCalled()
41
+ })
42
+ })
@@ -0,0 +1,77 @@
1
+ import { Button, type IconProp } from '@talelk/newui-atoms'
2
+ import { Dropdown } from '@talelk/newui-molecules'
3
+ import { type JSX, Show } from 'solid-js'
4
+
5
+ export interface PageToolbarAction {
6
+ label: string
7
+ /** A `lucide-*` string or a component (P11). */
8
+ icon?: IconProp
9
+ onClick?: () => void
10
+ }
11
+
12
+ export interface PageToolbarProps {
13
+ title: JSX.Element
14
+ /** Options for the ellipsis menu beside the title. Omitted → no menu. */
15
+ menu?: PageToolbarAction[]
16
+ /** The primary action — the one thing this screen is for. */
17
+ action?: PageToolbarAction
18
+ /** Anything else on the trailing edge, after the primary action. */
19
+ children?: JSX.Element
20
+ class?: string
21
+ }
22
+
23
+ /**
24
+ * The page header: a title, an optional title menu, and the actions.
25
+ *
26
+ * Small, but it is the three details inside it that were being re-decided on
27
+ * every screen: the title menu is an ellipsis *button* (a chevron next to a
28
+ * title reads as a dropdown for the title, which is exactly what it is — but
29
+ * the ellipsis is the convention), the primary action carries a leading `+` only
30
+ * when it creates something, and the header is `PageHeader` rather than a bare
31
+ * sticky div so it flips to the pinned mobile variant with the shell.
32
+ */
33
+ export function PageToolbar(props: PageToolbarProps): JSX.Element {
34
+ return (
35
+ <header
36
+ class={`flex min-h-12 shrink-0 items-center justify-between gap-control-gap border-b border-outline-gray-1 bg-surface-base px-page-gutter-compact sm:px-page-gutter ${props.class ?? ''}`}
37
+ >
38
+ <div class="flex min-w-0 items-center gap-1">
39
+ <span class="truncate text-heading text-ink-gray-9">{props.title}</span>
40
+ <Show when={props.menu}>
41
+ {(menu) => (
42
+ <Dropdown
43
+ options={menu().map((entry) => ({
44
+ label: entry.label,
45
+ value: entry.label,
46
+ ...(typeof entry.icon === 'string' ? { icon: entry.icon } : {}),
47
+ }))}
48
+ onSelect={(value) =>
49
+ menu()
50
+ .find((entry) => entry.label === value)
51
+ ?.onClick?.()
52
+ }
53
+ >
54
+ <span class="sr-only">Page actions</span>
55
+ </Dropdown>
56
+ )}
57
+ </Show>
58
+ </div>
59
+
60
+ <div class="flex shrink-0 items-center gap-control-gap">
61
+ {props.children}
62
+ <Show when={props.action}>
63
+ {(action) => (
64
+ <Button
65
+ variant="solid"
66
+ theme="blue"
67
+ iconLeft={action().icon ?? 'lucide-plus'}
68
+ onClick={action().onClick}
69
+ >
70
+ {action().label}
71
+ </Button>
72
+ )}
73
+ </Show>
74
+ </div>
75
+ </header>
76
+ )
77
+ }
@@ -0,0 +1 @@
1
+ export * from './PageToolbar'
@@ -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 { SettingsDialog } from './SettingsDialog'
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('SettingsDialog accessibility', () => {
22
+ it('has no axe violations when open', async () => {
23
+ render(() => (
24
+ <SettingsDialog.Root defaultOpen defaultValue="profile">
25
+ <SettingsDialog.Sidebar>
26
+ <SettingsDialog.NavGroup label="User settings">
27
+ <SettingsDialog.NavItem value="profile">
28
+ Profile
29
+ </SettingsDialog.NavItem>
30
+ <SettingsDialog.NavItem value="notifications">
31
+ Notifications
32
+ </SettingsDialog.NavItem>
33
+ </SettingsDialog.NavGroup>
34
+ </SettingsDialog.Sidebar>
35
+ <SettingsDialog.Content>
36
+ <SettingsDialog.Panel value="profile">
37
+ <SettingsDialog.Header title="Profile" description="Your details" />
38
+ <SettingsDialog.Body>
39
+ <SettingsDialog.Row
40
+ title="Display name"
41
+ description="Shown to others"
42
+ labelFor="display-name"
43
+ >
44
+ <input id="display-name" type="text" />
45
+ </SettingsDialog.Row>
46
+ </SettingsDialog.Body>
47
+ </SettingsDialog.Panel>
48
+ <SettingsDialog.Panel value="notifications">
49
+ <SettingsDialog.Header title="Notifications" />
50
+ <SettingsDialog.Body>Nothing here.</SettingsDialog.Body>
51
+ </SettingsDialog.Panel>
52
+ </SettingsDialog.Content>
53
+ </SettingsDialog.Root>
54
+ ))
55
+
56
+ expect(summarize(await axe.run(document.body, AXE_OPTIONS))).toEqual([])
57
+ })
58
+ })
@@ -0,0 +1,43 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { SettingsDialog } from './SettingsDialog'
4
+
5
+ describe('SettingsDialog (browser, real CSS)', () => {
6
+ it('paints the dialog, sidebar and nav rows', () => {
7
+ render(() => (
8
+ <SettingsDialog.Root defaultOpen>
9
+ <SettingsDialog.Sidebar>
10
+ <SettingsDialog.NavItem value="general">
11
+ General
12
+ </SettingsDialog.NavItem>
13
+ </SettingsDialog.Sidebar>
14
+ <SettingsDialog.Content>
15
+ <SettingsDialog.Panel value="general">
16
+ <SettingsDialog.Header title="General" />
17
+ <SettingsDialog.Body>Body</SettingsDialog.Body>
18
+ </SettingsDialog.Panel>
19
+ </SettingsDialog.Content>
20
+ </SettingsDialog.Root>
21
+ ))
22
+
23
+ const content = document.querySelector(
24
+ '[data-slot="dialog-content"]',
25
+ ) as HTMLElement
26
+ expect(content).not.toBeNull()
27
+ expect(getComputedStyle(content).backgroundColor).not.toBe(
28
+ 'rgba(0, 0, 0, 0)',
29
+ )
30
+
31
+ const sidebar = document.querySelector(
32
+ '[data-slot="settings-sidebar"]',
33
+ ) as HTMLElement
34
+ expect(getComputedStyle(sidebar).backgroundColor).not.toBe(
35
+ 'rgba(0, 0, 0, 0)',
36
+ )
37
+
38
+ const navItem = document.querySelector(
39
+ '[data-slot="settings-nav-item"]',
40
+ ) as HTMLElement
41
+ expect(navItem.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
42
+ })
43
+ })
@@ -0,0 +1,27 @@
1
+ import { renderToString } from 'solid-js/web'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { SettingsDialog } from './SettingsDialog'
4
+
5
+ describe('SSR smoke', () => {
6
+ it('SettingsDialog renders its closed state without a dialog', () => {
7
+ const html = renderToString(() => (
8
+ <SettingsDialog.Root>
9
+ <SettingsDialog.Sidebar>
10
+ <SettingsDialog.NavGroup label="User settings">
11
+ <SettingsDialog.NavItem value="profile">
12
+ Profile
13
+ </SettingsDialog.NavItem>
14
+ </SettingsDialog.NavGroup>
15
+ </SettingsDialog.Sidebar>
16
+ <SettingsDialog.Content>
17
+ <SettingsDialog.Panel value="profile">
18
+ Profile body
19
+ </SettingsDialog.Panel>
20
+ </SettingsDialog.Content>
21
+ </SettingsDialog.Root>
22
+ ))
23
+
24
+ expect(html).not.toContain('role="dialog"')
25
+ expect(html).not.toContain('data-slot="settings-sidebar"')
26
+ })
27
+ })
@@ -0,0 +1,202 @@
1
+ import { render, screen } from '@solidjs/testing-library'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { createSignal } from 'solid-js'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { SettingsDialog } from './SettingsDialog'
6
+
7
+ interface ExampleProps {
8
+ open?: boolean
9
+ value?: string
10
+ defaultValue?: string
11
+ onValueChange?: (value: string) => void
12
+ onOpenChange?: (open: boolean) => void
13
+ }
14
+
15
+ function Example(props: ExampleProps) {
16
+ return (
17
+ <SettingsDialog.Root
18
+ open={props.open}
19
+ defaultOpen={props.open === undefined ? true : undefined}
20
+ onOpenChange={props.onOpenChange}
21
+ value={props.value}
22
+ defaultValue={props.defaultValue}
23
+ onValueChange={props.onValueChange}
24
+ >
25
+ <SettingsDialog.Sidebar>
26
+ <SettingsDialog.NavGroup label="User settings">
27
+ <SettingsDialog.NavItem value="profile">
28
+ Profile
29
+ </SettingsDialog.NavItem>
30
+ <SettingsDialog.NavItem value="notifications">
31
+ Notifications
32
+ </SettingsDialog.NavItem>
33
+ </SettingsDialog.NavGroup>
34
+ </SettingsDialog.Sidebar>
35
+ <SettingsDialog.Content>
36
+ <SettingsDialog.Panel value="profile">
37
+ <SettingsDialog.Header title="Profile" />
38
+ <SettingsDialog.Body>Profile body</SettingsDialog.Body>
39
+ </SettingsDialog.Panel>
40
+ <SettingsDialog.Panel value="notifications">
41
+ <SettingsDialog.Header title="Notifications" />
42
+ <SettingsDialog.Body>Notifications body</SettingsDialog.Body>
43
+ </SettingsDialog.Panel>
44
+ </SettingsDialog.Content>
45
+ </SettingsDialog.Root>
46
+ )
47
+ }
48
+
49
+ describe('SettingsDialog', () => {
50
+ it('opens on request and closes on Escape', async () => {
51
+ function Harness() {
52
+ const [open, setOpen] = createSignal(false)
53
+ return (
54
+ <div>
55
+ <button type="button" onClick={() => setOpen(true)}>
56
+ open settings
57
+ </button>
58
+ <SettingsDialog.Root open={open()} onOpenChange={setOpen}>
59
+ <SettingsDialog.Sidebar>
60
+ <SettingsDialog.NavItem value="profile">
61
+ Profile
62
+ </SettingsDialog.NavItem>
63
+ </SettingsDialog.Sidebar>
64
+ <SettingsDialog.Content>
65
+ <SettingsDialog.Panel value="profile">
66
+ Profile body
67
+ </SettingsDialog.Panel>
68
+ </SettingsDialog.Content>
69
+ </SettingsDialog.Root>
70
+ </div>
71
+ )
72
+ }
73
+
74
+ render(() => <Harness />)
75
+ expect(screen.queryByRole('dialog')).toBeNull()
76
+
77
+ await userEvent.click(screen.getByRole('button', { name: 'open settings' }))
78
+ expect(screen.getByRole('dialog')).toBeTruthy()
79
+
80
+ await userEvent.keyboard('{Escape}')
81
+ expect(screen.queryByRole('dialog')).toBeNull()
82
+ })
83
+
84
+ it('reports a close through onOpenChange', async () => {
85
+ const onOpenChange = vi.fn()
86
+ render(() => <Example open onOpenChange={onOpenChange} />)
87
+
88
+ await userEvent.keyboard('{Escape}')
89
+ expect(onOpenChange).toHaveBeenCalledWith(false)
90
+ })
91
+
92
+ it('marks the selected nav item active', () => {
93
+ render(() => <Example defaultValue="profile" />)
94
+
95
+ const profile = screen.getByRole('tab', { name: 'Profile' })
96
+ const notifications = screen.getByRole('tab', { name: 'Notifications' })
97
+
98
+ expect(profile.getAttribute('aria-selected')).toBe('true')
99
+ expect(profile.getAttribute('data-state')).toBe('active')
100
+ expect(notifications.getAttribute('aria-selected')).toBe('false')
101
+ expect(notifications.getAttribute('data-state')).toBe('inactive')
102
+ })
103
+
104
+ it('switches panels when a nav item is clicked', async () => {
105
+ const onValueChange = vi.fn()
106
+ render(() => (
107
+ <Example defaultValue="profile" onValueChange={onValueChange} />
108
+ ))
109
+
110
+ expect(screen.getByRole('tabpanel').textContent).toContain('Profile body')
111
+
112
+ await userEvent.click(screen.getByRole('tab', { name: 'Notifications' }))
113
+
114
+ expect(onValueChange).toHaveBeenCalledWith('notifications')
115
+ expect(screen.getByRole('tabpanel').textContent).toContain(
116
+ 'Notifications body',
117
+ )
118
+ })
119
+
120
+ it('keeps every panel in the DOM, hiding the inactive ones', () => {
121
+ render(() => <Example defaultValue="profile" />)
122
+
123
+ const panels = document.querySelectorAll('[data-slot="settings-panel"]')
124
+ expect(panels).toHaveLength(2)
125
+ expect(panels[0]?.getAttribute('data-state')).toBe('active')
126
+ expect(panels[1]?.getAttribute('data-state')).toBe('inactive')
127
+ })
128
+
129
+ it('honours an explicit active prop over the dialog value', () => {
130
+ render(() => (
131
+ <SettingsDialog.Root defaultOpen defaultValue="beta">
132
+ <SettingsDialog.Sidebar>
133
+ <SettingsDialog.NavItem value="alpha" active>
134
+ Alpha
135
+ </SettingsDialog.NavItem>
136
+ <SettingsDialog.NavItem value="beta">Beta</SettingsDialog.NavItem>
137
+ </SettingsDialog.Sidebar>
138
+ </SettingsDialog.Root>
139
+ ))
140
+
141
+ expect(
142
+ screen.getByRole('tab', { name: 'Alpha' }).getAttribute('data-state'),
143
+ ).toBe('active')
144
+ })
145
+
146
+ it('renders the header title, description and actions', () => {
147
+ render(() => (
148
+ <SettingsDialog.Root defaultOpen defaultValue="profile">
149
+ <SettingsDialog.Content>
150
+ <SettingsDialog.Panel value="profile">
151
+ <SettingsDialog.Header
152
+ title="Profile"
153
+ description="Your details"
154
+ actions={<button type="button">Save</button>}
155
+ />
156
+ <SettingsDialog.Body>Body</SettingsDialog.Body>
157
+ </SettingsDialog.Panel>
158
+ </SettingsDialog.Content>
159
+ </SettingsDialog.Root>
160
+ ))
161
+
162
+ expect(screen.getByText('Profile')).toBeTruthy()
163
+ expect(screen.getByText('Your details')).toBeTruthy()
164
+ expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy()
165
+ })
166
+
167
+ it('associates a row title with the control through labelFor', () => {
168
+ render(() => (
169
+ <SettingsDialog.Root defaultOpen defaultValue="profile">
170
+ <SettingsDialog.Content>
171
+ <SettingsDialog.Panel value="profile">
172
+ <SettingsDialog.Row title="Display name" labelFor="display-name">
173
+ <input id="display-name" type="text" />
174
+ </SettingsDialog.Row>
175
+ </SettingsDialog.Panel>
176
+ </SettingsDialog.Content>
177
+ </SettingsDialog.Root>
178
+ ))
179
+
180
+ const label = screen.getByText('Display name')
181
+ expect(label.tagName).toBe('LABEL')
182
+ expect(label.getAttribute('for')).toBe('display-name')
183
+ })
184
+
185
+ it('detects the control id for the row title when labelFor is omitted', () => {
186
+ render(() => (
187
+ <SettingsDialog.Root defaultOpen defaultValue="profile">
188
+ <SettingsDialog.Content>
189
+ <SettingsDialog.Panel value="profile">
190
+ <SettingsDialog.Row title="Display name">
191
+ <input id="auto-name" type="text" />
192
+ </SettingsDialog.Row>
193
+ </SettingsDialog.Panel>
194
+ </SettingsDialog.Content>
195
+ </SettingsDialog.Root>
196
+ ))
197
+
198
+ const label = screen.getByText('Display name')
199
+ expect(label.tagName).toBe('LABEL')
200
+ expect(label.getAttribute('for')).toBe('auto-name')
201
+ })
202
+ })