@talelk/newui-organisms 2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@talelk/newui-organisms",
3
- "version": "2.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.1",
18
- "@talelk/newui-molecules": "2.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
+ })
@@ -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
+ })
@@ -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
+ })
@@ -0,0 +1,52 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { DataState } from './DataState'
4
+
5
+ function nextFrame() {
6
+ return new Promise((resolve) => requestAnimationFrame(() => resolve(null)))
7
+ }
8
+
9
+ describe('DataState (browser, real CSS)', () => {
10
+ it('renders loading skeletons as real painted rows', async () => {
11
+ const { container } = render(() => (
12
+ <DataState status="loading" skeleton="rows" rows={3} />
13
+ ))
14
+ await nextFrame()
15
+ const rows = container.querySelectorAll(
16
+ '[data-slot="skeleton"]',
17
+ ) as unknown as HTMLElement[]
18
+ expect(rows.length).toBe(3)
19
+ const row = rows[0] as HTMLElement
20
+ // `h-9` per row — a dropped height utility collapses the placeholder.
21
+ expect(row.getBoundingClientRect().height).toBeGreaterThanOrEqual(32)
22
+ // The paint lives on the shimmering line inside the row wrapper.
23
+ const line = row.querySelector('[data-slot="skeleton-line"]') as HTMLElement
24
+ expect(line).toBeTruthy()
25
+ expect(getComputedStyle(line).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
26
+ })
27
+
28
+ it('paints the empty state icon tile', () => {
29
+ const { container } = render(() => (
30
+ <DataState status="empty" title="No expenses" />
31
+ ))
32
+ const tile = container.querySelector(
33
+ '[data-slot="empty-state-icon"]',
34
+ ) as HTMLElement
35
+ expect(tile.getBoundingClientRect().width).toBeGreaterThan(0)
36
+ expect(getComputedStyle(tile).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
37
+ })
38
+
39
+ it('keeps the retry button a real target', async () => {
40
+ const { container } = render(() => (
41
+ <DataState status="error" error="The request failed" onRetry={() => {}} />
42
+ ))
43
+ await nextFrame()
44
+ const button = Array.from(container.querySelectorAll('button')).find(
45
+ (node) => node.textContent === 'Try again',
46
+ ) as HTMLElement
47
+ expect(button.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
48
+ expect(getComputedStyle(button).backgroundColor).not.toBe(
49
+ 'rgba(0, 0, 0, 0)',
50
+ )
51
+ })
52
+ })
@@ -0,0 +1,71 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { DataTable, type DataTableColumn } from './DataTable'
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
+ interface Row {
22
+ id: string
23
+ name: string
24
+ amount: number
25
+ }
26
+
27
+ const ROWS: Row[] = [
28
+ { id: '1', name: 'Alpha', amount: 10 },
29
+ { id: '2', name: 'Beta', amount: 20 },
30
+ { id: '3', name: 'Gamma', amount: 30 },
31
+ ]
32
+
33
+ const COLUMNS: DataTableColumn<Row>[] = [
34
+ { accessorKey: 'name', header: 'Name' },
35
+ {
36
+ accessorKey: 'amount',
37
+ header: 'Amount',
38
+ cell: (context) => `$${context.getValue()}`,
39
+ meta: { align: 'end' },
40
+ },
41
+ ]
42
+
43
+ describe('DataTable accessibility', () => {
44
+ it('has no axe violations for a selectable table', async () => {
45
+ const { container } = render(() => (
46
+ <DataTable
47
+ title="Expenses"
48
+ rows={ROWS}
49
+ columns={COLUMNS}
50
+ pageSize={10}
51
+ actionLabel="Approve"
52
+ onRowsAction={() => {}}
53
+ recordsLabel="expenses"
54
+ />
55
+ ))
56
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
57
+ })
58
+
59
+ it('has no axe violations for a read-only table', async () => {
60
+ const { container } = render(() => (
61
+ <DataTable
62
+ title="Expenses"
63
+ rows={ROWS}
64
+ columns={COLUMNS}
65
+ pageSize={10}
66
+ selectable={false}
67
+ />
68
+ ))
69
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
70
+ })
71
+ })
@@ -0,0 +1,191 @@
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 { DataTable, type DataTableColumn } from './DataTable'
6
+
7
+ interface Row {
8
+ id: string
9
+ name: string
10
+ amount: number
11
+ }
12
+
13
+ const ROWS: Row[] = [
14
+ { id: '1', name: 'Alpha', amount: 10 },
15
+ { id: '2', name: 'Beta', amount: 20 },
16
+ ]
17
+
18
+ const COLUMNS: DataTableColumn<Row>[] = [
19
+ { accessorKey: 'name', header: 'Name' },
20
+ {
21
+ accessorKey: 'amount',
22
+ header: 'Amount',
23
+ meta: { align: 'end', editable: true, editInput: 'number' },
24
+ },
25
+ ]
26
+
27
+ describe('DataTable pinning', () => {
28
+ it('freezes the named columns to each scroll edge', () => {
29
+ const { container } = render(() => (
30
+ <DataTable
31
+ title="Rows"
32
+ rows={ROWS}
33
+ columns={COLUMNS}
34
+ pageSize={10}
35
+ selectable={false}
36
+ pinnedStart="name"
37
+ pinnedEnd="amount"
38
+ />
39
+ ))
40
+ const head = container.querySelector('thead') as HTMLElement
41
+ const startTh = head.querySelector('th[scope="col"]') as HTMLElement
42
+ expect(startTh.className).toContain('sticky')
43
+ expect(startTh.className).toContain('start-0')
44
+ const endTh = head.querySelectorAll('th[scope="col"]')[1] as HTMLElement
45
+ expect(endTh.className).toContain('end-0')
46
+ // Body cells carry the same stickiness as their header.
47
+ const firstTd = container.querySelector('tbody td') as HTMLElement
48
+ expect(firstTd.className).toContain('sticky')
49
+ })
50
+
51
+ it('leaves unpinned tables in plain flow', () => {
52
+ const { container } = render(() => (
53
+ <DataTable
54
+ title="Rows"
55
+ rows={ROWS}
56
+ columns={COLUMNS}
57
+ pageSize={10}
58
+ selectable={false}
59
+ />
60
+ ))
61
+ const th = container.querySelector('th[scope="col"]') as HTMLElement
62
+ expect(th.className).not.toContain('sticky')
63
+ })
64
+
65
+ it('answers onPinnedColumnsChange only from the app (no in-table UI mutates pinning)', () => {
66
+ const onPinnedColumnsChange = vi.fn()
67
+ render(() => (
68
+ <DataTable
69
+ title="Rows"
70
+ rows={ROWS}
71
+ columns={COLUMNS}
72
+ pageSize={10}
73
+ selectable={false}
74
+ pinnedStart="name"
75
+ onPinnedColumnsChange={onPinnedColumnsChange}
76
+ />
77
+ ))
78
+ // Sorting (the only in-table toggle) does not move pinning.
79
+ const sortButton = screen.getByRole('button', { name: /Name/ })
80
+ userEvent.click(sortButton)
81
+ expect(onPinnedColumnsChange).not.toHaveBeenCalled()
82
+ })
83
+ })
84
+
85
+ describe('DataTable inline editing', () => {
86
+ it('renders a click-to-edit cell that reports the raw value', async () => {
87
+ const onCellChange = vi.fn()
88
+ const { container } = render(() => (
89
+ <DataTable
90
+ title="Rows"
91
+ rows={ROWS}
92
+ columns={COLUMNS}
93
+ pageSize={10}
94
+ selectable={false}
95
+ editable
96
+ onCellChange={onCellChange}
97
+ />
98
+ ))
99
+ const trigger = screen.getByRole('button', {
100
+ name: /— edit Amount of 1$/,
101
+ })
102
+ expect(trigger.textContent).toBe('10')
103
+ await userEvent.click(trigger)
104
+ const input = screen.getByRole('spinbutton', {
105
+ name: 'Edit Amount of 1',
106
+ }) as HTMLInputElement
107
+ expect(document.activeElement).toBe(input)
108
+ await userEvent.clear(input)
109
+ await userEvent.type(input, '15{Enter}')
110
+ expect(onCellChange).toHaveBeenCalledWith('1', 'amount', '15')
111
+ // The edit trigger is gone once editing closes.
112
+ expect(
113
+ container.querySelector('[data-slot="data-table-edit-trigger"]'),
114
+ ).toBeTruthy()
115
+ })
116
+
117
+ it('escapes an edit without reporting', async () => {
118
+ const onCellChange = vi.fn()
119
+ render(() => (
120
+ <DataTable
121
+ title="Rows"
122
+ rows={ROWS}
123
+ columns={COLUMNS}
124
+ pageSize={10}
125
+ selectable={false}
126
+ editable
127
+ onCellChange={onCellChange}
128
+ />
129
+ ))
130
+ await userEvent.click(
131
+ screen.getByRole('button', { name: /— edit Amount of 1$/ }),
132
+ )
133
+ await userEvent.keyboard('999{Escape}')
134
+ expect(onCellChange).not.toHaveBeenCalled()
135
+ // The cell shows the untouched value again.
136
+ expect(
137
+ screen.getByRole('button', { name: /— edit Amount of 1$/ }).textContent,
138
+ ).toBe('10')
139
+ })
140
+
141
+ it('renders the updated value when the app owns the mutation', async () => {
142
+ const [rows, setRows] = createSignal<Row[]>(ROWS)
143
+ const { container } = render(() => (
144
+ <DataTable
145
+ title="Rows"
146
+ rows={rows()}
147
+ columns={COLUMNS}
148
+ pageSize={10}
149
+ selectable={false}
150
+ editable
151
+ onCellChange={(id, columnId, value) =>
152
+ setRows((current) =>
153
+ current.map((row) =>
154
+ row.id === id ? { ...row, [columnId]: Number(value) } : row,
155
+ ),
156
+ )
157
+ }
158
+ />
159
+ ))
160
+ await userEvent.click(
161
+ screen.getByRole('button', { name: /— edit Amount of 2$/ }),
162
+ )
163
+ const input = screen.getByRole('spinbutton', {
164
+ name: 'Edit Amount of 2',
165
+ }) as HTMLInputElement
166
+ await userEvent.clear(input)
167
+ await userEvent.type(input, '25{Enter}')
168
+ await new Promise((resolve) => setTimeout(resolve, 0))
169
+ // The editor closed onto the app-owned new value.
170
+ const body = container.querySelector('tbody') as HTMLElement
171
+ expect(body.textContent).toContain('25')
172
+ expect(
173
+ screen.queryByRole('spinbutton', { name: 'Edit Amount of 2' }),
174
+ ).toBeNull()
175
+ })
176
+
177
+ it('renders plain cells when not editable', () => {
178
+ render(() => (
179
+ <DataTable
180
+ title="Rows"
181
+ rows={ROWS}
182
+ columns={COLUMNS}
183
+ pageSize={10}
184
+ selectable={false}
185
+ />
186
+ ))
187
+ expect(
188
+ screen.queryByRole('button', { name: /— edit Amount of 1$/ }),
189
+ ).toBeNull()
190
+ })
191
+ })