@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,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,80 @@
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
+ /** Social sign-in buttons, in order. */
17
+ providers?: AuthProvider[]
18
+ /** The line under the form: "Don't have an account? Sign up". */
19
+ footer?: JSX.Element
20
+ /** Brand mark above the title. */
21
+ logo?: JSX.Element
22
+ class?: string
23
+ }
24
+
25
+ /**
26
+ * The centred card a sign-in, sign-up or reset screen is.
27
+ *
28
+ * One block rather than three screens, because the three are the same layout
29
+ * with different fields — and because the details that make it look
30
+ * intentional are the ones that get skipped when it is written inline: a capped
31
+ * measure so the fields do not stretch on a wide display, the divider labelled
32
+ * "or" only when both a form and providers are present, and the title as an
33
+ * `h1` so the page has exactly one.
34
+ */
35
+ export function AuthCard(props: AuthCardProps): JSX.Element {
36
+ const hasProviders = () => (props.providers?.length ?? 0) > 0
37
+
38
+ return (
39
+ <div
40
+ data-slot="auth-card"
41
+ class={`flex min-h-screen items-center justify-center bg-surface-base px-4 py-12 ${props.class ?? ''}`}
42
+ >
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>
52
+ </div>
53
+
54
+ <div class="flex flex-col gap-4">{props.children}</div>
55
+
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>
73
+
74
+ <Show when={props.footer}>
75
+ <p class="text-center text-base text-ink-gray-7">{props.footer}</p>
76
+ </Show>
77
+ </div>
78
+ </div>
79
+ )
80
+ }
@@ -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
+ })