@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,232 @@
1
+ import { Button, Icon, TextInput } from '@talelk/newui-atoms'
2
+ import { Select, type SelectOption, TabButtons } from '@talelk/newui-molecules'
3
+ import { For, type JSX, onCleanup, onMount, Show } from 'solid-js'
4
+
5
+ export interface FilterOption {
6
+ label: string
7
+ value: string
8
+ }
9
+
10
+ export interface FilterSelect {
11
+ /** Accessible name; also the placeholder when nothing is chosen. */
12
+ label: string
13
+ options: SelectOption[]
14
+ value?: string
15
+ onValueChange?: (value: string) => void
16
+ width?: string
17
+ }
18
+
19
+ export interface FilterChip {
20
+ /** The active filter as the reader states it, e.g. `Status: Open`. */
21
+ label: string
22
+ /** When set, the chip carries a remove button that calls this. */
23
+ onRemove?: () => void
24
+ }
25
+
26
+ export interface FilterBarProps {
27
+ /** Rendered as a search input. Omit for a bar with no query box. */
28
+ query?: {
29
+ value?: string
30
+ placeholder?: string
31
+ onInput?: (value: string) => void
32
+ width?: string
33
+ }
34
+ /** One or more selects, in order. */
35
+ selects?: FilterSelect[]
36
+ /** A segmented control on the trailing edge — the view switcher. */
37
+ tabs?: {
38
+ options: FilterOption[]
39
+ value?: string
40
+ onValueChange?: (value: string) => void
41
+ }
42
+ /** Active filters restated as removable pills under the bar. */
43
+ chips?: FilterChip[]
44
+ /**
45
+ * Renders a "Clear all" button while a filter is active (a chip, a filled
46
+ * select, or a non-empty query). The bar never clears anything itself —
47
+ * it reports the request and the caller resets its own state.
48
+ */
49
+ onClearAll?: () => void
50
+ /** Rows the current filters match, stated under the bar (`24 results`). */
51
+ resultCount?: number
52
+ /** Anything else on the trailing edge, after the tabs. */
53
+ children?: JSX.Element
54
+ class?: string
55
+ }
56
+
57
+ /** True when a keystroke is landing in something the user is already editing. */
58
+ function isEditable(target: EventTarget | null): boolean {
59
+ if (!(target instanceof HTMLElement)) return false
60
+ return (
61
+ target.isContentEditable ||
62
+ target instanceof HTMLInputElement ||
63
+ target instanceof HTMLTextAreaElement ||
64
+ target instanceof HTMLSelectElement
65
+ )
66
+ }
67
+
68
+ /**
69
+ * The row of controls above a list: search, one or two selects, and the view
70
+ * switcher, on one quiet surface so the controls read as a single toolbar
71
+ * rather than loose fields above the data.
72
+ *
73
+ * Worth a block because the shape is a decision — search leads and is the widest
74
+ * target, selects are fixed-width so their labels do not jump as values change,
75
+ * and the view switcher is pushed to the far end because it changes how the data
76
+ * below is *drawn* rather than what is in it. A filled select takes the filled
77
+ * (`subtle`) surface, so what is filtering the list is visible without opening
78
+ * anything; the active set can also be restated as removable chips with a
79
+ * result count and a clear-all, which is the point where a row of controls
80
+ * becomes an answer to "why am I seeing these rows".
81
+ *
82
+ * The filter bar gives each Select an `aria-label` from its `label` and uses
83
+ * it as the placeholder, rather than rendering a form label above a toolbar.
84
+ * The search box carries a `/` hint and honours it: pressing `/` anywhere
85
+ * outside another editing surface focuses the box.
86
+ */
87
+ export function FilterBar(props: FilterBarProps): JSX.Element {
88
+ let searchBox: HTMLSpanElement | undefined
89
+
90
+ onMount(() => {
91
+ if (!props.query) return
92
+ const onKey = (event: KeyboardEvent) => {
93
+ if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey)
94
+ return
95
+ if (isEditable(event.target)) return
96
+ const input = searchBox?.querySelector('input')
97
+ if (!input) return
98
+ event.preventDefault()
99
+ input.focus()
100
+ }
101
+ window.addEventListener('keydown', onKey)
102
+ onCleanup(() => window.removeEventListener('keydown', onKey))
103
+ })
104
+
105
+ const activeCount = () =>
106
+ (props.chips?.length ?? 0) +
107
+ (props.selects ?? []).filter((select) => Boolean(select.value)).length +
108
+ (props.query?.value ? 1 : 0)
109
+ const showClearAll = () => props.onClearAll !== undefined && activeCount() > 0
110
+ const showMetaRow = () =>
111
+ (props.chips?.length ?? 0) > 0 ||
112
+ props.resultCount !== undefined ||
113
+ showClearAll()
114
+
115
+ return (
116
+ <div
117
+ data-slot="filter-bar"
118
+ class={`flex max-w-full flex-col gap-control-gap rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-control-pad ${props.class ?? ''}`}
119
+ >
120
+ <div class="grid max-w-full grid-cols-1 items-center gap-control-gap sm:grid-cols-2 xl:flex xl:flex-wrap">
121
+ <Show when={props.query}>
122
+ {(query) => (
123
+ <span
124
+ ref={searchBox}
125
+ class="col-span-full min-w-0 max-w-full xl:flex-1"
126
+ >
127
+ <TextInput
128
+ size="sm"
129
+ type="search"
130
+ value={query().value}
131
+ placeholder={query().placeholder ?? 'Search…'}
132
+ aria-label={query().placeholder ?? 'Search'}
133
+ class={`w-full ${query().width ?? ''}`}
134
+ prefix={<Icon icon="lucide-search" class="size-4" />}
135
+ suffix={
136
+ <kbd class="rounded-2 border border-outline-gray-2 bg-surface-gray-1 px-1 text-caption text-ink-gray-7">
137
+ /
138
+ </kbd>
139
+ }
140
+ onInput={(event) =>
141
+ query().onInput?.(event.currentTarget.value)
142
+ }
143
+ />
144
+ </span>
145
+ )}
146
+ </Show>
147
+
148
+ <For each={props.selects ?? []}>
149
+ {(select) => (
150
+ <div
151
+ class={`min-w-0 max-w-full sm:w-full xl:w-40 ${select.width ?? ''}`}
152
+ >
153
+ <Select
154
+ size="sm"
155
+ variant={select.value ? 'subtle' : 'outline'}
156
+ options={select.options}
157
+ value={select.value || null}
158
+ placeholder={select.label}
159
+ aria-label={select.label}
160
+ onValueChange={(value) => select.onValueChange?.(value ?? '')}
161
+ />
162
+ </div>
163
+ )}
164
+ </For>
165
+
166
+ <div class="col-span-full flex min-w-0 max-w-full flex-wrap items-center gap-control-gap sm:justify-end xl:ms-auto">
167
+ <Show when={props.tabs}>
168
+ {(tabs) => (
169
+ <TabButtons
170
+ options={tabs().options}
171
+ value={tabs().value}
172
+ onValueChange={tabs().onValueChange}
173
+ />
174
+ )}
175
+ </Show>
176
+ {props.children}
177
+ </div>
178
+ </div>
179
+
180
+ <Show when={showMetaRow()}>
181
+ <div
182
+ data-slot="filter-bar-meta"
183
+ class="flex max-w-full flex-wrap items-center gap-1.5 px-control-pad"
184
+ >
185
+ <Show when={props.resultCount !== undefined}>
186
+ <span
187
+ aria-live="polite"
188
+ class="me-1 text-caption tabular-nums text-ink-gray-7"
189
+ >
190
+ {props.resultCount}{' '}
191
+ {props.resultCount === 1 ? 'result' : 'results'}
192
+ </span>
193
+ </Show>
194
+ <For each={props.chips ?? []}>
195
+ {(chip) => (
196
+ <span
197
+ data-slot="filter-chip"
198
+ class={`inline-flex items-center gap-1 rounded-full border border-outline-gray-2 bg-surface-base py-0.5 ps-2 text-caption text-ink-gray-8 ${chip.onRemove ? 'pe-1' : 'pe-2'}`}
199
+ >
200
+ {chip.label}
201
+ <Show when={chip.onRemove}>
202
+ {(onRemove) => (
203
+ <button
204
+ type="button"
205
+ aria-label={`Remove filter: ${chip.label}`}
206
+ class="grid size-4 place-items-center rounded-full text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring motion-reduce:transition-none"
207
+ onClick={() => onRemove()()}
208
+ >
209
+ <Icon icon="lucide-x" class="size-3" />
210
+ </button>
211
+ )}
212
+ </Show>
213
+ </span>
214
+ )}
215
+ </For>
216
+ <Show when={showClearAll()}>
217
+ <span class="ms-auto">
218
+ <Button
219
+ variant="ghost"
220
+ theme="gray"
221
+ size="sm"
222
+ onClick={() => props.onClearAll?.()}
223
+ >
224
+ Clear all
225
+ </Button>
226
+ </span>
227
+ </Show>
228
+ </div>
229
+ </Show>
230
+ </div>
231
+ )
232
+ }
@@ -0,0 +1 @@
1
+ export * from './FilterBar'
@@ -0,0 +1,255 @@
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 {
5
+ KanbanBoard,
6
+ type KanbanColumn,
7
+ type KanbanMove,
8
+ placeKanbanItem,
9
+ } from './KanbanBoard'
10
+
11
+ interface Deal {
12
+ id: string
13
+ name: string
14
+ }
15
+
16
+ const deals: Deal[] = [
17
+ { id: 'D-1', name: 'Globex' },
18
+ { id: 'D-2', name: 'Stark' },
19
+ { id: 'D-3', name: 'Wayne' },
20
+ ]
21
+
22
+ function stages(
23
+ overrides: Partial<Record<string, Deal[]>> = {},
24
+ ): KanbanColumn<Deal>[] {
25
+ return [
26
+ {
27
+ id: 'open',
28
+ name: 'Open',
29
+ theme: 'gray',
30
+ items: overrides['open'] ?? deals,
31
+ },
32
+ {
33
+ id: 'won',
34
+ name: 'Won',
35
+ theme: 'green',
36
+ items: overrides['won'] ?? [],
37
+ },
38
+ ]
39
+ }
40
+
41
+ const card = (deal: Deal) => <span>{deal.name}</span>
42
+
43
+ const idsIn = (columnId: string) =>
44
+ [
45
+ ...document.querySelectorAll(
46
+ `[data-kanban-column="${columnId}"] [data-kanban-id]`,
47
+ ),
48
+ ].map((node) => node.getAttribute('data-kanban-id'))
49
+
50
+ /** Open a card's menu and choose a column. */
51
+ async function moveViaMenu(name: string, column: string) {
52
+ await userEvent.click(
53
+ screen.getByRole('button', { name: `Actions for ${name}` }),
54
+ )
55
+ await userEvent.click(await screen.findByRole('menuitem', { name: column }))
56
+ }
57
+
58
+ describe('placeKanbanItem', () => {
59
+ const columns = stages()
60
+
61
+ it('moves an item to the end of another column by index', () => {
62
+ const next = placeKanbanItem(columns, 'D-1', { column: 'won', index: 0 })
63
+ expect(next[0]?.items.map((item) => item.id)).toEqual(['D-2', 'D-3'])
64
+ expect(next[1]?.items.map((item) => item.id)).toEqual(['D-1'])
65
+ })
66
+
67
+ it('counts the index over the column *without* the item being moved', () => {
68
+ // Dragging D-1 down one place: the index is measured after D-1 is taken out,
69
+ // so 1 lands it between D-2 and D-3 rather than back at the top.
70
+ const next = placeKanbanItem(columns, 'D-1', { column: 'open', index: 1 })
71
+ expect(next[0]?.items.map((item) => item.id)).toEqual(['D-2', 'D-1', 'D-3'])
72
+ })
73
+
74
+ it('clamps an index past the end of the column', () => {
75
+ const next = placeKanbanItem(columns, 'D-1', { column: 'won', index: 9 })
76
+ expect(next[1]?.items.map((item) => item.id)).toEqual(['D-1'])
77
+ })
78
+
79
+ it('returns what came in when the move changes nothing', () => {
80
+ expect(placeKanbanItem(columns, 'D-1', { column: 'open', index: 0 })).toBe(
81
+ columns,
82
+ )
83
+ })
84
+
85
+ it('keeps the columns it did not touch, by reference', () => {
86
+ const next = placeKanbanItem(columns, 'D-1', { column: 'won', index: 0 })
87
+ // The untouched column is the same object: keying rows on it would throw
88
+ // that column's DOM away, along with its scroll position.
89
+ expect(next[0]).not.toBe(columns[0])
90
+ expect(next[1]).not.toBe(columns[1])
91
+ expect(next[0]?.name).toBe('Open')
92
+ })
93
+
94
+ it('ignores an item the board does not have', () => {
95
+ expect(placeKanbanItem(columns, 'D-9', { column: 'won', index: 0 })).toBe(
96
+ columns,
97
+ )
98
+ })
99
+ })
100
+
101
+ describe('KanbanBoard', () => {
102
+ it('renders the columns, their counts and the cards', () => {
103
+ render(() => (
104
+ <KanbanBoard columns={stages()} label="Deals">
105
+ {card}
106
+ </KanbanBoard>
107
+ ))
108
+
109
+ expect(screen.getByRole('list', { name: 'Deals' })).toBeTruthy()
110
+ expect(screen.getByRole('heading', { name: 'Open', level: 2 })).toBeTruthy()
111
+ expect(screen.getByText('Globex')).toBeTruthy()
112
+ // The count is the column's, not the card's.
113
+ expect(screen.getByText('3')).toBeTruthy()
114
+ })
115
+
116
+ it('renders a column action, a summary and its empty state', async () => {
117
+ const onAdd = vi.fn()
118
+ render(() => (
119
+ <KanbanBoard
120
+ columns={[
121
+ {
122
+ id: 'open',
123
+ name: 'Open',
124
+ items: [],
125
+ summary: <span>$210,000</span>,
126
+ action: { label: 'Add deal', onClick: onAdd },
127
+ },
128
+ ]}
129
+ emptyColumn={() => <span>Nothing open.</span>}
130
+ >
131
+ {card}
132
+ </KanbanBoard>
133
+ ))
134
+
135
+ expect(screen.getByText('$210,000')).toBeTruthy()
136
+ expect(screen.getByText('Nothing open.')).toBeTruthy()
137
+ await userEvent.click(screen.getByRole('button', { name: 'Add deal' }))
138
+ expect(onAdd).toHaveBeenCalled()
139
+ })
140
+
141
+ it('moves a card with the menu, and reports a single move', async () => {
142
+ const moves: KanbanMove<Deal>[] = []
143
+ render(() => (
144
+ <KanbanBoard
145
+ defaultColumns={stages()}
146
+ onMove={(move) => moves.push(move)}
147
+ itemLabel={(deal) => deal.name}
148
+ >
149
+ {card}
150
+ </KanbanBoard>
151
+ ))
152
+
153
+ await moveViaMenu('Globex', 'Won')
154
+
155
+ expect(moves).toHaveLength(1)
156
+ expect(moves[0]?.itemId).toBe('D-1')
157
+ expect(moves[0]?.from).toEqual({ column: 'open', index: 0 })
158
+ expect(moves[0]?.to).toEqual({ column: 'won', index: 0 })
159
+ expect(moves[0]?.columns[1]?.items.map((item) => item.id)).toEqual(['D-1'])
160
+ })
161
+
162
+ it('only offers the columns the card is not already in', async () => {
163
+ render(() => (
164
+ <KanbanBoard defaultColumns={stages()} itemLabel={(deal) => deal.name}>
165
+ {card}
166
+ </KanbanBoard>
167
+ ))
168
+
169
+ await userEvent.click(
170
+ screen.getByRole('button', { name: 'Actions for Globex' }),
171
+ )
172
+ const entries = await screen.findAllByRole('menuitem')
173
+ expect(entries.map((entry) => entry.textContent)).toEqual(['Won'])
174
+ })
175
+
176
+ it('owns the order when uncontrolled, and leaves it alone when controlled', async () => {
177
+ const uncontrolled = render(() => (
178
+ <KanbanBoard defaultColumns={stages()} itemLabel={(deal) => deal.name}>
179
+ {card}
180
+ </KanbanBoard>
181
+ ))
182
+ expect(idsIn('won')).toEqual([])
183
+ await moveViaMenu('Globex', 'Won')
184
+ expect(idsIn('won')).toEqual(['D-1'])
185
+ expect(idsIn('open')).toEqual(['D-2', 'D-3'])
186
+ uncontrolled.unmount()
187
+
188
+ const columns = stages()
189
+ render(() => (
190
+ <KanbanBoard columns={columns} itemLabel={(deal) => deal.name}>
191
+ {card}
192
+ </KanbanBoard>
193
+ ))
194
+ await moveViaMenu('Globex', 'Won')
195
+ // The board reported the move but did not take it: the owner decides.
196
+ expect(idsIn('won')).toEqual([])
197
+ expect(idsIn('open')).toEqual(['D-1', 'D-2', 'D-3'])
198
+ })
199
+
200
+ it('lets a card bring its own actions instead', async () => {
201
+ const onArchive = vi.fn()
202
+ render(() => (
203
+ <KanbanBoard
204
+ defaultColumns={stages()}
205
+ itemLabel={(deal) => deal.name}
206
+ renderActions={() => (
207
+ <button type="button" onClick={onArchive}>
208
+ Archive
209
+ </button>
210
+ )}
211
+ >
212
+ {card}
213
+ </KanbanBoard>
214
+ ))
215
+
216
+ await userEvent.click(
217
+ screen.getAllByRole('button', { name: 'Archive' })[0] as HTMLElement,
218
+ )
219
+ expect(onArchive).toHaveBeenCalled()
220
+ expect(screen.queryByRole('button', { name: /Actions for/ })).toBeNull()
221
+ })
222
+
223
+ it('snaps the columns only where the board is a phone-width panner', () => {
224
+ render(() => (
225
+ <KanbanBoard defaultColumns={stages()} itemLabel={(deal) => deal.name}>
226
+ {card}
227
+ </KanbanBoard>
228
+ ))
229
+
230
+ const row = screen.getByRole('list', { name: 'Board' })
231
+ const classes = row.className.split(/\s+/)
232
+ // Every snap utility is behind `max-lg:`. An unprefixed one would snap the
233
+ // board on a wide screen too, under the pointer, mid-drop.
234
+ expect(classes.filter((name) => name.includes('snap'))).toEqual([
235
+ 'max-lg:snap-x',
236
+ 'max-lg:snap-mandatory',
237
+ ])
238
+ const column = document.querySelector('[data-kanban-column="open"]')
239
+ expect(column?.className).toContain('max-lg:snap-start')
240
+ })
241
+
242
+ it('names the board and each card for a screen reader', () => {
243
+ render(() => (
244
+ <KanbanBoard defaultColumns={stages()} itemLabel={(deal) => deal.name}>
245
+ {card}
246
+ </KanbanBoard>
247
+ ))
248
+
249
+ expect(screen.getByRole('list', { name: 'Board' })).toBeTruthy()
250
+ expect(
251
+ screen.getByRole('button', { name: 'Actions for Globex' }),
252
+ ).toBeTruthy()
253
+ expect(screen.getByRole('heading', { name: 'Won' })).toBeTruthy()
254
+ })
255
+ })