@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.
@@ -1,4 +1,5 @@
1
1
  import { Button, Checkbox, cn, TextInput } from '@talelk/newui-atoms'
2
+ import { createControllableSignal } from '@talelk/newui-atoms/internal'
2
3
  import {
3
4
  Pagination,
4
5
  ScrollTable,
@@ -7,6 +8,7 @@ import {
7
8
  import {
8
9
  type ColumnDef,
9
10
  columnFilteringFeature,
11
+ columnPinningFeature,
10
12
  columnVisibilityFeature,
11
13
  createFilteredRowModel,
12
14
  createPaginatedRowModel,
@@ -25,6 +27,7 @@ import {
25
27
  import {
26
28
  createEffect,
27
29
  createMemo,
30
+ createSignal,
28
31
  For,
29
32
  type JSX,
30
33
  on,
@@ -44,6 +47,7 @@ import { SelectionBar } from '../SelectionBar'
44
47
  function createFeatures() {
45
48
  return tableFeatures({
46
49
  columnFilteringFeature,
50
+ columnPinningFeature,
47
51
  columnVisibilityFeature,
48
52
  globalFilteringFeature,
49
53
  rowPaginationFeature,
@@ -72,10 +76,22 @@ export type DataTableMobileRole =
72
76
  | 'status'
73
77
  | 'meta'
74
78
 
75
- /** The library reads `meta.align` and `meta.mobile`; both are optional. */
79
+ /** The library reads `meta.align`, `meta.mobile` and `meta.editable`; all optional. */
76
80
  export interface DataTableColumnMeta {
77
81
  align?: 'end'
78
82
  mobile?: DataTableMobileRole
83
+ /**
84
+ * The cell edits in place when the table is `editable`. `editInput` picks
85
+ * the editor (default `text`).
86
+ */
87
+ editable?: boolean
88
+ editInput?: 'text' | 'number'
89
+ }
90
+
91
+ /** Which column is frozen to each edge of the horizontal scroll. */
92
+ export interface DataTablePinned {
93
+ start?: string
94
+ end?: string
79
95
  }
80
96
 
81
97
  export interface DataTableProps<T extends { id: string }> {
@@ -110,10 +126,97 @@ export interface DataTableProps<T extends { id: string }> {
110
126
  emptyHint?: string
111
127
  /** Empty-state reset for externally filtered pages (beats Clear search). */
112
128
  onClearFilters?: () => void
129
+ /**
130
+ * Freeze one column to the start edge of the horizontal scroll — the
131
+ * identifier that must stay visible while the rest move. Controlled:
132
+ * changes go through `onPinnedColumnsChange`.
133
+ */
134
+ pinnedStart?: string
135
+ /** Freeze one column to the end edge — typically the row actions. */
136
+ pinnedEnd?: string
137
+ defaultPinnedStart?: string
138
+ defaultPinnedEnd?: string
139
+ onPinnedColumnsChange?: (pinned: DataTablePinned) => void
140
+ /**
141
+ * Turn on inline editing: columns whose `meta.editable` is set render a
142
+ * click-to-edit cell. Values are app-owned — edits arrive through
143
+ * `onCellChange`, and the table shows whatever the next `rows` carry.
144
+ */
145
+ editable?: boolean
146
+ onCellChange?: (id: string, columnId: string, value: string) => void
113
147
  }
114
148
 
115
149
  /**
116
- * A collection as a table: search, sort, page, hide columns, select rows.
150
+ * A click-to-edit cell. Enter or blur commits; Escape cancels. The value is
151
+ * the row's raw field, not the formatted render.
152
+ */
153
+ function EditableCell(props: {
154
+ id: string
155
+ columnId: string
156
+ value: string
157
+ label: string
158
+ type: 'text' | 'number'
159
+ alignEnd: boolean
160
+ onCommit: (id: string, columnId: string, value: string) => void
161
+ }): JSX.Element {
162
+ const [editing, setEditing] = createSignal(false)
163
+ // Seeded at open time (the trigger click), never read while closed.
164
+ const [draft, setDraft] = createSignal('')
165
+ const commit = (original: string) => {
166
+ setEditing(false)
167
+ const value = draft().trim()
168
+ if (value !== original) props.onCommit(props.id, props.columnId, value)
169
+ }
170
+ return (
171
+ <Show
172
+ when={editing()}
173
+ fallback={
174
+ <button
175
+ type="button"
176
+ data-slot="data-table-edit-trigger"
177
+ // The name leads with the visible text: a speech user saying what
178
+ // they see must still land on this control.
179
+ aria-label={`${props.value || '—'} — edit ${props.label} of ${props.id}`}
180
+ class={cn(
181
+ '-mx-1 w-full cursor-text rounded-3 px-1 py-0.5 text-start hover:bg-surface-gray-2 focus-visible:focus-ring',
182
+ props.alignEnd && 'text-end',
183
+ )}
184
+ onClick={() => {
185
+ setDraft(props.value)
186
+ setEditing(true)
187
+ }}
188
+ >
189
+ <Show
190
+ when={props.value !== ''}
191
+ fallback={<span class="text-ink-gray-5">—</span>}
192
+ >
193
+ {props.value}
194
+ </Show>
195
+ </button>
196
+ }
197
+ >
198
+ <TextInput
199
+ aria-label={`Edit ${props.label} of ${props.id}`}
200
+ // The editor opens on the user's click; focus belongs there.
201
+ ref={(el) => queueMicrotask(() => el.focus())}
202
+ type={props.type}
203
+ value={draft()}
204
+ size="xs"
205
+ onInput={(event) => setDraft(event.currentTarget.value)}
206
+ onBlur={() => commit(props.value)}
207
+ onKeyDown={(event) => {
208
+ if (event.key === 'Enter') commit(props.value)
209
+ if (event.key === 'Escape') setEditing(false)
210
+ }}
211
+ class="min-w-0"
212
+ />
213
+ </Show>
214
+ )
215
+ }
216
+
217
+ /**
218
+ * A collection as a table: search, sort, page, hide columns, select rows,
219
+ * pin one column to each scroll edge, edit cells in place.
117
220
  *
118
221
  * TanStack Table is the engine; this is the kit's composition of it. The surface
119
222
  * lives in `@talelk/newui-organisms`: it composes controls and owns the TanStack Table
@@ -152,6 +255,38 @@ export function DataTable<T extends { id: string }>(
152
255
  const resolvedColumns = createMemo(() =>
153
256
  selectable() ? [selection, ...props.columns] : props.columns,
154
257
  )
258
+ const [pinned] = createControllableSignal<DataTablePinned>({
259
+ value: () => ({
260
+ start: props.pinnedStart,
261
+ end: props.pinnedEnd,
262
+ }),
263
+ defaultValue: {
264
+ start: props.defaultPinnedStart,
265
+ end: props.defaultPinnedEnd,
266
+ },
267
+ onChange: (next) => props.onPinnedColumnsChange?.(next),
268
+ })
269
+ const editable = () =>
270
+ props.editable === true && props.onCellChange !== undefined
271
+ const editMeta = (meta: unknown) => {
272
+ const columnMeta = meta as DataTableColumnMeta | undefined
273
+ return columnMeta?.editable === true
274
+ ? { input: columnMeta.editInput ?? 'text' }
275
+ : undefined
276
+ }
277
+ /** Which edge (if any) a column id is frozen to. */
278
+ const pinOf = (columnId: string): 'start' | 'end' | undefined => {
279
+ if (pinned().start === columnId) return 'start'
280
+ if (pinned().end === columnId) return 'end'
281
+ return undefined
282
+ }
283
+ /** Sticky cell chrome: opaque card surface over the scrolling columns. */
284
+ const pinClass = (edge: 'start' | 'end' | undefined) =>
285
+ edge === 'start'
286
+ ? 'sticky start-0 z-raised bg-surface-elevation-1 group-hover/row:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:group-hover/row:bg-surface-blue-2 border-e border-outline-gray-1'
287
+ : edge === 'end'
288
+ ? 'sticky end-0 z-raised bg-surface-elevation-1 group-hover/row:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:group-hover/row:bg-surface-blue-2 border-s border-outline-gray-1'
289
+ : ''
155
290
  const table = createTable<Features, T>({
156
291
  features: createFeatures(),
157
292
  get data() {
@@ -160,6 +295,15 @@ export function DataTable<T extends { id: string }>(
160
295
  get columns() {
161
296
  return resolvedColumns()
162
297
  },
298
+ get state() {
299
+ const pinnedValue = pinned()
300
+ return {
301
+ columnPinning: {
302
+ start: pinnedValue.start ? [pinnedValue.start] : [],
303
+ end: pinnedValue.end ? [pinnedValue.end] : [],
304
+ },
305
+ }
306
+ },
163
307
  getRowId: (row) => row.id,
164
308
  get enableRowSelection() {
165
309
  return (row: { original: T }) =>
@@ -455,11 +599,12 @@ export function DataTable<T extends { id: string }>(
455
599
  {(header) => (
456
600
  <th
457
601
  scope="col"
458
- class={
602
+ class={cn(
459
603
  alignEnd(header.column.columnDef.meta)
460
604
  ? `${cellPad()} text-end`
461
- : `${cellPad()} text-start`
462
- }
605
+ : `${cellPad()} text-start`,
606
+ pinClass(pinOf(header.column.id)),
607
+ )}
463
608
  aria-sort={
464
609
  header.column.getIsSorted() === 'asc'
465
610
  ? 'ascending'
@@ -513,24 +658,61 @@ export function DataTable<T extends { id: string }>(
513
658
  <For each={table.getRowModel().rows}>
514
659
  {(row) => (
515
660
  <tr
516
- class="border-b border-outline-gray-1 hover:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:hover:bg-surface-blue-2"
661
+ class="group/row border-b border-outline-gray-1 hover:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:hover:bg-surface-blue-2"
517
662
  data-selected={row.getIsSelected() ? '' : undefined}
518
663
  >
519
664
  <For each={row.getVisibleCells()}>
520
- {(cell) => (
521
- <td
522
- class={
523
- alignEnd(cell.column.columnDef.meta)
524
- ? `${cellPad()} text-end text-interface`
525
- : `${cellPad()} text-interface`
526
- }
527
- >
528
- {flexRender(
529
- cell.column.columnDef.cell,
530
- cell.getContext(),
531
- )}
532
- </td>
533
- )}
665
+ {(cell) => {
666
+ const edge = pinOf(cell.column.id)
667
+ const editingMeta = editable()
668
+ ? editMeta(cell.column.columnDef.meta)
669
+ : undefined
670
+ const raw = (
671
+ row.original as Record<string, unknown>
672
+ )[cell.column.id]
673
+ return (
674
+ <td
675
+ class={cn(
676
+ alignEnd(cell.column.columnDef.meta)
677
+ ? `${cellPad()} text-end text-interface`
678
+ : `${cellPad()} text-interface`,
679
+ pinClass(edge),
680
+ )}
681
+ data-selected={
682
+ row.getIsSelected() ? '' : undefined
683
+ }
684
+ >
685
+ <Show
686
+ when={editingMeta}
687
+ fallback={flexRender(
688
+ cell.column.columnDef.cell,
689
+ cell.getContext(),
690
+ )}
691
+ >
692
+ <EditableCell
693
+ id={row.original.id}
694
+ columnId={cell.column.id}
695
+ value={raw == null ? '' : String(raw)}
696
+ label={columnLabel(
697
+ cell.column as unknown as {
698
+ id: string
699
+ columnDef: { header?: unknown }
700
+ },
701
+ )}
702
+ type={
703
+ editingMeta?.input === 'number'
704
+ ? 'number'
705
+ : 'text'
706
+ }
707
+ alignEnd={alignEnd(
708
+ cell.column.columnDef.meta,
709
+ )}
710
+ onCommit={props.onCellChange ?? (() => {})}
711
+ />
712
+ </Show>
713
+ </td>
714
+ )
715
+ }}
534
716
  </For>
535
717
  </tr>
536
718
  )}
@@ -0,0 +1,53 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { DetailHeader } from './DetailHeader'
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('DetailHeader accessibility', () => {
22
+ it('has no axe violations for a record header', async () => {
23
+ const { container } = render(() => (
24
+ <DetailHeader
25
+ breadcrumbs={[
26
+ { label: 'Files', route: '/files' },
27
+ { label: 'Reports', route: '/files/reports' },
28
+ { label: 'Q3' },
29
+ ]}
30
+ title="Q3 report.pdf"
31
+ subtitle="1.2 MB · updated 2h ago"
32
+ status={{ label: 'Shared', theme: 'blue' }}
33
+ actions={[
34
+ { label: 'Share', onClick: () => {} },
35
+ { label: 'Download', onClick: () => {} },
36
+ ]}
37
+ />
38
+ ))
39
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
40
+ })
41
+
42
+ it('has no axe violations with a lower heading level', async () => {
43
+ const { container } = render(() => (
44
+ <DetailHeader
45
+ title="INV-1042"
46
+ headingLevel={2}
47
+ subtitle="Unpaid · due Oct 30"
48
+ actions={[{ label: 'Edit', onClick: () => {} }]}
49
+ />
50
+ ))
51
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
52
+ })
53
+ })
@@ -0,0 +1,50 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { DetailHeader } from './DetailHeader'
4
+
5
+ describe('DetailHeader (browser, real CSS)', () => {
6
+ it('sets the title in the metric scale above the muted subtitle', () => {
7
+ const { container } = render(() => (
8
+ <DetailHeader title="Q3 report.pdf" subtitle="1.2 MB · updated 2h ago" />
9
+ ))
10
+ const title = container.querySelector('h1') as HTMLElement
11
+ const subtitle = container.querySelector('p') as HTMLElement
12
+ // `text-metric` is 1.75rem — the largest type on a record page.
13
+ expect(Number.parseFloat(getComputedStyle(title).fontSize)).toBeGreaterThan(
14
+ 24,
15
+ )
16
+ expect(Number.parseFloat(getComputedStyle(subtitle).fontSize)).toBeLessThan(
17
+ Number.parseFloat(getComputedStyle(title).fontSize),
18
+ )
19
+ })
20
+
21
+ it('keeps the action buttons real, with the primary one painted', () => {
22
+ const { container } = render(() => (
23
+ <DetailHeader
24
+ title="Report"
25
+ actions={[{ label: 'Share' }, { label: 'Download' }]}
26
+ />
27
+ ))
28
+ const buttons = Array.from(container.querySelectorAll('button'))
29
+ const primary = buttons.at(-1) as HTMLElement
30
+ expect(primary.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
31
+ // `variant="solid"` must paint — a transparent primary is an invisible CTA.
32
+ expect(getComputedStyle(primary).backgroundColor).not.toBe(
33
+ 'rgba(0, 0, 0, 0)',
34
+ )
35
+ })
36
+
37
+ it('draws the status pill as a rounded chip', () => {
38
+ const { container } = render(() => (
39
+ <DetailHeader
40
+ title="Report"
41
+ status={{ label: 'Shared', theme: 'blue' }}
42
+ />
43
+ ))
44
+ const badge = container.querySelector('[data-slot="badge"]') as HTMLElement
45
+ const style = getComputedStyle(badge)
46
+ expect(style.borderRadius).not.toBe('0px')
47
+ expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
48
+ expect(badge.getBoundingClientRect().height).toBeGreaterThan(0)
49
+ })
50
+ })
@@ -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 { FilterBar } from './FilterBar'
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('FilterBar accessibility', () => {
22
+ it('has no axe violations for a fully loaded bar', async () => {
23
+ const { container } = render(() => (
24
+ <FilterBar
25
+ query={{ placeholder: 'Search expenses', value: 'taxi' }}
26
+ selects={[
27
+ {
28
+ label: 'Owner',
29
+ value: 'me',
30
+ options: [
31
+ { label: 'Anyone', value: 'any' },
32
+ { label: 'Me', value: 'me' },
33
+ ],
34
+ },
35
+ ]}
36
+ tabs={{
37
+ label: 'View',
38
+ value: 'list',
39
+ options: [
40
+ { label: 'List', value: 'list' },
41
+ { label: 'Board', value: 'board' },
42
+ ],
43
+ }}
44
+ chips={[{ label: 'Status: Open', onRemove: () => {} }]}
45
+ resultCount={24}
46
+ onClearAll={() => {}}
47
+ />
48
+ ))
49
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
50
+ })
51
+
52
+ it('has no axe violations for a bare bar with only a search box', async () => {
53
+ const { container } = render(() => (
54
+ <FilterBar surface="bare" query={{ placeholder: 'Search tickets' }} />
55
+ ))
56
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
57
+ })
58
+ })
@@ -0,0 +1,46 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { FilterBar } from './FilterBar'
4
+
5
+ describe('FilterBar (browser, real CSS)', () => {
6
+ it('puts the controls on one painted card surface, or none when bare', () => {
7
+ const card = render(() => <FilterBar query={{ placeholder: 'Search' }} />)
8
+ const bar = card.container.querySelector(
9
+ '[data-slot="filter-bar"]',
10
+ ) as HTMLElement
11
+ const style = getComputedStyle(bar)
12
+ expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
13
+ expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
14
+ expect(Number.parseFloat(style.borderRadius)).toBeGreaterThan(0)
15
+ expect(style.paddingTop).not.toBe('0px')
16
+ card.unmount()
17
+
18
+ const bare = render(() => (
19
+ <FilterBar surface="bare" query={{ placeholder: 'Search' }} />
20
+ ))
21
+ const bareBar = bare.container.querySelector(
22
+ '[data-slot="filter-bar"]',
23
+ ) as HTMLElement
24
+ // `bare` is composed into a Section, so it must not paint its own card.
25
+ expect(getComputedStyle(bareBar).backgroundColor).toBe('rgba(0, 0, 0, 0)')
26
+ expect(getComputedStyle(bareBar).paddingTop).toBe('0px')
27
+ })
28
+
29
+ it('paints the / hint on the search box', () => {
30
+ const { container } = render(() => (
31
+ <FilterBar query={{ placeholder: 'Search tickets' }} />
32
+ ))
33
+ const hint = container.querySelector('kbd') as HTMLElement
34
+ expect(hint.getBoundingClientRect().width).toBeGreaterThan(0)
35
+ expect(getComputedStyle(hint).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
36
+ })
37
+
38
+ it('states the result count in tabular figures', () => {
39
+ const { container } = render(() => <FilterBar resultCount={24} />)
40
+ const count = container.querySelector(
41
+ '[data-slot="filter-bar-meta"] span',
42
+ ) as HTMLElement
43
+ // `tabular-nums` keeps 24 and 8 aligned as filters change the number.
44
+ expect(getComputedStyle(count).fontVariantNumeric).toContain('tabular-nums')
45
+ })
46
+ })
@@ -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 { KanbanBoard, type KanbanColumn } from './KanbanBoard'
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 Deal {
22
+ id: string
23
+ name: string
24
+ }
25
+
26
+ const deals: Deal[] = [
27
+ { id: 'D-1', name: 'Globex' },
28
+ { id: 'D-2', name: 'Stark' },
29
+ { id: 'D-3', name: 'Wayne' },
30
+ ]
31
+
32
+ function stages(): KanbanColumn<Deal>[] {
33
+ return [
34
+ { id: 'open', name: 'Open', theme: 'gray', items: deals },
35
+ { id: 'won', name: 'Won', theme: 'green', items: [] },
36
+ ]
37
+ }
38
+
39
+ const card = (deal: Deal) => <span>{deal.name}</span>
40
+
41
+ describe('KanbanBoard accessibility', () => {
42
+ it('has no axe violations for a board with cards and an empty column', async () => {
43
+ const { container } = render(() => (
44
+ <KanbanBoard
45
+ columns={stages()}
46
+ label="Deals"
47
+ itemLabel={(deal) => deal.name}
48
+ >
49
+ {card}
50
+ </KanbanBoard>
51
+ ))
52
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
53
+ })
54
+
55
+ it('has no axe violations with a column summary, action and empty copy', async () => {
56
+ const { container } = render(() => (
57
+ <KanbanBoard
58
+ label="Deals"
59
+ columns={[
60
+ {
61
+ id: 'open',
62
+ name: 'Open',
63
+ items: [],
64
+ summary: <span>$210,000</span>,
65
+ action: { label: 'Add deal', onClick: () => {} },
66
+ },
67
+ ]}
68
+ emptyColumn={() => <span>Nothing open.</span>}
69
+ >
70
+ {card}
71
+ </KanbanBoard>
72
+ ))
73
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
74
+ })
75
+ })
@@ -0,0 +1,84 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { KanbanBoard, type KanbanColumn } from './KanbanBoard'
4
+
5
+ interface Deal {
6
+ id: string
7
+ name: string
8
+ }
9
+
10
+ const deals: Deal[] = [
11
+ { id: 'D-1', name: 'Globex' },
12
+ { id: 'D-2', name: 'Stark' },
13
+ ]
14
+
15
+ function stages(): KanbanColumn<Deal>[] {
16
+ return [
17
+ { id: 'open', name: 'Open', theme: 'gray', items: deals },
18
+ { id: 'won', name: 'Won', theme: 'green', items: [] },
19
+ ]
20
+ }
21
+
22
+ const card = (deal: Deal) => <span>{deal.name}</span>
23
+
24
+ describe('KanbanBoard (browser, real CSS)', () => {
25
+ it('reserves a real column width on a tinted, bordered surface', () => {
26
+ const { container } = render(() => (
27
+ <KanbanBoard columns={stages()} label="Deals">
28
+ {card}
29
+ </KanbanBoard>
30
+ ))
31
+ const column = container.querySelector(
32
+ '[data-kanban-column="open"]',
33
+ ) as HTMLElement
34
+ // `w-72` (288px) below `min-[1440px]` — the board is a panner, columns do
35
+ // not shrink to fit.
36
+ const width = column.getBoundingClientRect().width
37
+ expect(width).toBeGreaterThanOrEqual(256)
38
+ expect(width).toBeLessThanOrEqual(300)
39
+ const style = getComputedStyle(column)
40
+ expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
41
+ expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
42
+ })
43
+
44
+ it('paints the status dot in the column theme', () => {
45
+ const { container } = render(() => (
46
+ <KanbanBoard columns={stages()} label="Deals">
47
+ {card}
48
+ </KanbanBoard>
49
+ ))
50
+ const dot = container.querySelector(
51
+ '[data-kanban-column="open"] span[aria-hidden="true"]',
52
+ ) as HTMLElement
53
+ const rect = dot.getBoundingClientRect()
54
+ expect(rect.width).toBeGreaterThan(0)
55
+ expect(getComputedStyle(dot).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
56
+ })
57
+
58
+ it('gives each card the grab cursor and a real box', () => {
59
+ const { container } = render(() => (
60
+ <KanbanBoard columns={stages()} label="Deals">
61
+ {card}
62
+ </KanbanBoard>
63
+ ))
64
+ const cardBox = container.querySelector(
65
+ '[data-kanban-id="D-1"] > [data-slot="card"]',
66
+ ) as HTMLElement
67
+ expect(cardBox.getBoundingClientRect().height).toBeGreaterThan(0)
68
+ expect(getComputedStyle(cardBox).cursor).toBe('grab')
69
+ })
70
+
71
+ it('draws the empty column as a dashed drop hint', () => {
72
+ const { container } = render(() => (
73
+ <KanbanBoard columns={stages()} label="Deals">
74
+ {card}
75
+ </KanbanBoard>
76
+ ))
77
+ const empty = container.querySelector(
78
+ '[data-kanban-column="won"] ul li',
79
+ ) as HTMLElement
80
+ const style = getComputedStyle(empty)
81
+ expect(style.borderTopStyle).toBe('dashed')
82
+ expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
83
+ })
84
+ })