@lovett/ui 0.0.4 → 0.0.6
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/dist/index.d.ts +2659 -0
- package/dist/index.js +14451 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +636 -0
- package/dist/tokens.css +700 -0
- package/package.json +44 -17
- package/src/__tests__/button.test.tsx +137 -0
- package/src/__tests__/card.test.tsx +103 -0
- package/src/__tests__/dead-render.test.tsx +117 -0
- package/src/__tests__/input.test.tsx +134 -0
- package/src/__tests__/modal.test.tsx +154 -0
- package/src/__tests__/page-shell.test.tsx +128 -0
- package/src/__tests__/setup.ts +43 -0
- package/src/__tests__/token-shape.test.ts +193 -0
- package/src/allocation-sparkbar.tsx +90 -0
- package/src/card.tsx +1 -1
- package/src/collapsible-card.tsx +85 -0
- package/src/data-grid/table-body.tsx +8 -1
- package/src/dropdown-menu.tsx +1 -1
- package/src/floating-status-bar.tsx +112 -0
- package/src/folder-tree-picker.tsx +5 -6
- package/src/frame-stack.tsx +27 -10
- package/src/hero-form-card.tsx +2 -2
- package/src/icons/brand.tsx +187 -0
- package/src/index.ts +43 -0
- package/src/lib/clipboard.ts +14 -0
- package/src/lib/color.ts +111 -0
- package/src/meta-cell.tsx +52 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
- package/src/meta-previews/MetaFeedPreview.tsx +1 -1
- package/src/microsoft-logo.tsx +33 -0
- package/src/modal.tsx +77 -6
- package/src/pill-button.tsx +23 -5
- package/src/profile-section.tsx +40 -9
- package/src/sortable-table.tsx +5 -1
- package/src/styles.css +74 -0
- package/src/tabs.tsx +4 -0
- package/src/tag-chip-input.tsx +1 -1
- package/src/theme-v2.css +466 -0
- package/src/token-badge.tsx +92 -0
- package/src/tokens.css +181 -60
- package/src/v2/README.md +208 -0
- package/src/v2/__demo__/showcase.tsx +1045 -0
- package/src/v2/action.tsx +91 -0
- package/src/v2/callout.tsx +76 -0
- package/src/v2/document-section.tsx +82 -0
- package/src/v2/document-shell.tsx +0 -0
- package/src/v2/field-row.tsx +113 -0
- package/src/v2/icons.tsx +165 -0
- package/src/v2/index.ts +147 -0
- package/src/v2/layout.tsx +293 -0
- package/src/v2/progress-track.tsx +89 -0
- package/src/v2/stat-tile.tsx +129 -0
- package/src/v2/states.tsx +271 -0
- package/src/v2/status-pill.tsx +74 -0
- package/src/v2/theme.css +1861 -0
- package/src/v2/timeline.tsx +81 -0
- package/src/v2/tokens.ts +228 -0
- package/src/value-chip.tsx +76 -0
package/package.json
CHANGED
|
@@ -1,28 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovett/ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"private": false,
|
|
5
|
-
"publishConfig": {
|
|
6
|
-
"access": "restricted"
|
|
7
|
-
},
|
|
8
5
|
"type": "module",
|
|
9
6
|
"description": "Design system primitives, tokens, layouts, and patterns for the Lovett portfolio.",
|
|
10
|
-
"
|
|
11
|
-
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"**/*.css"
|
|
9
|
+
],
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
12
|
"exports": {
|
|
13
|
-
".":
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./tokens.css": "./dist/tokens.css",
|
|
18
|
+
"./styles.css": "./dist/styles.css",
|
|
19
|
+
"./theme-v2.css": "./dist/theme-v2.css",
|
|
20
|
+
"./v2": {
|
|
21
|
+
"types": "./dist/v2/index.d.ts",
|
|
22
|
+
"import": "./dist/v2/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./v2/theme.css": "./dist/v2/theme.css"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "restricted"
|
|
16
28
|
},
|
|
17
29
|
"files": [
|
|
30
|
+
"dist",
|
|
18
31
|
"src",
|
|
19
32
|
"README.md"
|
|
20
33
|
],
|
|
21
|
-
"scripts": {
|
|
22
|
-
"lint": "eslint .",
|
|
23
|
-
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
24
|
-
"build": "echo 'no-op (source-only consumption in v0)'"
|
|
25
|
-
},
|
|
26
34
|
"peerDependencies": {
|
|
27
35
|
"lucide-react": "^0.469.0",
|
|
28
36
|
"react": "^19.0.0",
|
|
@@ -30,13 +38,23 @@
|
|
|
30
38
|
"react-router-dom": "^7.1.0"
|
|
31
39
|
},
|
|
32
40
|
"devDependencies": {
|
|
41
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
42
|
+
"@testing-library/react": "^16.3.2",
|
|
43
|
+
"@testing-library/user-event": "^14.6.1",
|
|
44
|
+
"@types/node": "^22",
|
|
33
45
|
"@types/react": "^19.0.0",
|
|
34
46
|
"@types/react-dom": "^19.0.0",
|
|
35
47
|
"@types/react-syntax-highlighter": "^15.5.13",
|
|
48
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
36
49
|
"eslint": "^9.17.0",
|
|
50
|
+
"jsdom": "^29.1.1",
|
|
37
51
|
"lucide-react": "^0.469.0",
|
|
52
|
+
"react": "^19.0.0",
|
|
53
|
+
"react-dom": "^19.0.0",
|
|
38
54
|
"react-router-dom": "^7.1.0",
|
|
39
|
-
"
|
|
55
|
+
"tsup": "^8.5.1",
|
|
56
|
+
"typescript": "^5.7.2",
|
|
57
|
+
"vitest": "^4.1.6"
|
|
40
58
|
},
|
|
41
59
|
"dependencies": {
|
|
42
60
|
"@dnd-kit/core": "^6.3.1",
|
|
@@ -49,5 +67,14 @@
|
|
|
49
67
|
"remark-gfm": "^4.0.1",
|
|
50
68
|
"sonner": "^2.0.7",
|
|
51
69
|
"tailwind-merge": "^3.6.0"
|
|
52
|
-
}
|
|
53
|
-
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"lint": "eslint .",
|
|
73
|
+
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
|
|
74
|
+
"test": "vitest run",
|
|
75
|
+
"test:ci": "vitest run --no-file-parallelism",
|
|
76
|
+
"build": "tsup",
|
|
77
|
+
"clean": "rm -rf dist"
|
|
78
|
+
},
|
|
79
|
+
"module": "./dist/index.js"
|
|
80
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button — 254 consuming files, previously zero tests.
|
|
3
|
+
*
|
|
4
|
+
* These assert the contract every consumer relies on, not appearance:
|
|
5
|
+
* the base class survives a caller `className`, the ref reaches the
|
|
6
|
+
* element, rest props land on the DOM node, and loading implies disabled.
|
|
7
|
+
*/
|
|
8
|
+
import { createRef } from 'react'
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { render, screen } from '@testing-library/react'
|
|
11
|
+
import userEvent from '@testing-library/user-event'
|
|
12
|
+
import { Button } from '../button'
|
|
13
|
+
|
|
14
|
+
describe('Button', () => {
|
|
15
|
+
it('renders its children in a <button>', () => {
|
|
16
|
+
render(<Button>Save</Button>)
|
|
17
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('defaults to type="button" so it never submits a form by accident', () => {
|
|
21
|
+
render(<Button>Save</Button>)
|
|
22
|
+
expect(screen.getByRole('button')).toHaveAttribute('type', 'button')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('honours an explicit type', () => {
|
|
26
|
+
render(<Button type="submit">Save</Button>)
|
|
27
|
+
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('defaults to the primary variant at md', () => {
|
|
31
|
+
render(<Button>Save</Button>)
|
|
32
|
+
const btn = screen.getByRole('button')
|
|
33
|
+
expect(btn).toHaveClass('btn', 'btn-primary')
|
|
34
|
+
// md is the default and maps to no size modifier.
|
|
35
|
+
expect(btn.className).not.toMatch(/size-(sm|lg)/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it.each([
|
|
39
|
+
['primary', 'btn-primary'],
|
|
40
|
+
['secondary', 'btn-secondary'],
|
|
41
|
+
['outline', 'btn-outline'],
|
|
42
|
+
['ghost', 'btn-ghost'],
|
|
43
|
+
['destructive', 'btn-destructive'],
|
|
44
|
+
['destructive-soft', 'btn-destructive-soft'],
|
|
45
|
+
['icon', 'btn-icon'],
|
|
46
|
+
['toolbar', 'btn-toolbar'],
|
|
47
|
+
] as const)('maps variant=%s to .%s', (variant, expected) => {
|
|
48
|
+
render(<Button variant={variant}>x</Button>)
|
|
49
|
+
expect(screen.getByRole('button')).toHaveClass('btn', expected)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it.each([
|
|
53
|
+
['sm', 'size-sm'],
|
|
54
|
+
['lg', 'size-lg'],
|
|
55
|
+
] as const)('maps size=%s to .%s', (size, expected) => {
|
|
56
|
+
render(<Button size={size}>x</Button>)
|
|
57
|
+
expect(screen.getByRole('button')).toHaveClass(expected)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('applies is-circle only when shape=circle AND variant=icon', () => {
|
|
61
|
+
const { rerender } = render(
|
|
62
|
+
<Button shape="circle" variant="icon">
|
|
63
|
+
x
|
|
64
|
+
</Button>,
|
|
65
|
+
)
|
|
66
|
+
expect(screen.getByRole('button')).toHaveClass('is-circle')
|
|
67
|
+
|
|
68
|
+
rerender(
|
|
69
|
+
<Button shape="circle" variant="primary">
|
|
70
|
+
x
|
|
71
|
+
</Button>,
|
|
72
|
+
)
|
|
73
|
+
expect(screen.getByRole('button')).not.toHaveClass('is-circle')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('MERGES a caller className rather than replacing the base classes', () => {
|
|
77
|
+
// Five other primitives use `className ??` replace semantics, which
|
|
78
|
+
// silently deletes their base layout. Button must not join them.
|
|
79
|
+
render(<Button className="w-full">x</Button>)
|
|
80
|
+
expect(screen.getByRole('button')).toHaveClass('btn', 'btn-primary', 'w-full')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('disables itself while loading and flags .is-loading', () => {
|
|
84
|
+
render(<Button loading>Saving</Button>)
|
|
85
|
+
const btn = screen.getByRole('button')
|
|
86
|
+
expect(btn).toBeDisabled()
|
|
87
|
+
expect(btn).toHaveClass('is-loading')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('does not fire onClick while loading', async () => {
|
|
91
|
+
const onClick = vi.fn()
|
|
92
|
+
render(
|
|
93
|
+
<Button loading onClick={onClick}>
|
|
94
|
+
Saving
|
|
95
|
+
</Button>,
|
|
96
|
+
)
|
|
97
|
+
await userEvent.click(screen.getByRole('button'))
|
|
98
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('fires onClick when enabled', async () => {
|
|
102
|
+
const onClick = vi.fn()
|
|
103
|
+
render(<Button onClick={onClick}>Save</Button>)
|
|
104
|
+
await userEvent.click(screen.getByRole('button'))
|
|
105
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('forwards a ref to the underlying <button>', () => {
|
|
109
|
+
const ref = createRef<HTMLButtonElement>()
|
|
110
|
+
render(<Button ref={ref}>x</Button>)
|
|
111
|
+
expect(ref.current).toBeInstanceOf(HTMLButtonElement)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('spreads rest props onto the DOM node', () => {
|
|
115
|
+
render(
|
|
116
|
+
<Button aria-describedby="hint" data-testid="cta">
|
|
117
|
+
x
|
|
118
|
+
</Button>,
|
|
119
|
+
)
|
|
120
|
+
const btn = screen.getByTestId('cta')
|
|
121
|
+
expect(btn).toHaveAttribute('aria-describedby', 'hint')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('renders leading and trailing icons around the label', () => {
|
|
125
|
+
render(
|
|
126
|
+
<Button
|
|
127
|
+
leadingIcon={<span data-testid="lead" />}
|
|
128
|
+
trailingIcon={<span data-testid="trail" />}
|
|
129
|
+
>
|
|
130
|
+
Label
|
|
131
|
+
</Button>,
|
|
132
|
+
)
|
|
133
|
+
expect(screen.getByTestId('lead')).toBeInTheDocument()
|
|
134
|
+
expect(screen.getByTestId('trail')).toBeInTheDocument()
|
|
135
|
+
expect(screen.getByRole('button')).toHaveTextContent('Label')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card — the nominal base surface.
|
|
3
|
+
*
|
|
4
|
+
* Its auto-wrap rule is the least obvious behaviour in the package and
|
|
5
|
+
* the one most likely to be "simplified" by a refactor: Head/Body get
|
|
6
|
+
* folded into a recessed tray while Foot stays on the outer frame, but
|
|
7
|
+
* only when the caller hasn't taken over tray layout themselves. All
|
|
8
|
+
* three branches are pinned here.
|
|
9
|
+
*/
|
|
10
|
+
import { createRef } from 'react'
|
|
11
|
+
import { describe, expect, it } from 'vitest'
|
|
12
|
+
import { render, screen } from '@testing-library/react'
|
|
13
|
+
import Card from '../card'
|
|
14
|
+
|
|
15
|
+
describe('Card', () => {
|
|
16
|
+
it('renders children', () => {
|
|
17
|
+
render(<Card>plain content</Card>)
|
|
18
|
+
expect(screen.getByText('plain content')).toBeInTheDocument()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('carries the surface class and merges a caller className', () => {
|
|
22
|
+
render(
|
|
23
|
+
<Card className="w-64" data-testid="card">
|
|
24
|
+
x
|
|
25
|
+
</Card>,
|
|
26
|
+
)
|
|
27
|
+
expect(screen.getByTestId('card')).toHaveClass('ds-card-surface', 'w-64')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('forwards a ref to the root element', () => {
|
|
31
|
+
const ref = createRef<HTMLDivElement>()
|
|
32
|
+
render(<Card ref={ref}>x</Card>)
|
|
33
|
+
expect(ref.current).toBeInstanceOf(HTMLDivElement)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('marks data-interactive only when interactive', () => {
|
|
37
|
+
const { rerender } = render(<Card data-testid="card">x</Card>)
|
|
38
|
+
expect(screen.getByTestId('card')).not.toHaveAttribute('data-interactive')
|
|
39
|
+
|
|
40
|
+
rerender(
|
|
41
|
+
<Card interactive data-testid="card">
|
|
42
|
+
x
|
|
43
|
+
</Card>,
|
|
44
|
+
)
|
|
45
|
+
expect(screen.getByTestId('card')).toHaveAttribute('data-interactive', 'true')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('auto-wrap', () => {
|
|
49
|
+
it('wraps Head/Body in a tray and leaves Foot on the frame', () => {
|
|
50
|
+
render(
|
|
51
|
+
<Card data-testid="card">
|
|
52
|
+
<Card.Head>head</Card.Head>
|
|
53
|
+
<Card.Body>body</Card.Body>
|
|
54
|
+
<Card.Foot>foot</Card.Foot>
|
|
55
|
+
</Card>,
|
|
56
|
+
)
|
|
57
|
+
const card = screen.getByTestId('card')
|
|
58
|
+
expect(card).toHaveAttribute('data-framed', 'true')
|
|
59
|
+
|
|
60
|
+
const tray = card.querySelector('.ds-card-tray')
|
|
61
|
+
expect(tray).not.toBeNull()
|
|
62
|
+
expect(tray).toHaveTextContent('head')
|
|
63
|
+
expect(tray).toHaveTextContent('body')
|
|
64
|
+
// Foot is a sibling of the tray, not inside it — this is what puts
|
|
65
|
+
// the footer on the outer frame.
|
|
66
|
+
expect(tray).not.toHaveTextContent('foot')
|
|
67
|
+
expect(card).toHaveTextContent('foot')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('renders flat when the caller supplies an explicit Tray', () => {
|
|
71
|
+
render(
|
|
72
|
+
<Card data-testid="card">
|
|
73
|
+
<Card.Tray>managed</Card.Tray>
|
|
74
|
+
</Card>,
|
|
75
|
+
)
|
|
76
|
+
const card = screen.getByTestId('card')
|
|
77
|
+
expect(card).not.toHaveAttribute('data-framed')
|
|
78
|
+
// Exactly one tray — the caller's, not an added wrapper.
|
|
79
|
+
expect(card.querySelectorAll('.ds-card-tray')).toHaveLength(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('renders flat when no named slot is used', () => {
|
|
83
|
+
render(
|
|
84
|
+
<Card data-testid="card">
|
|
85
|
+
<div>custom</div>
|
|
86
|
+
</Card>,
|
|
87
|
+
)
|
|
88
|
+
const card = screen.getByTestId('card')
|
|
89
|
+
expect(card).not.toHaveAttribute('data-framed')
|
|
90
|
+
expect(card.querySelector('.ds-card-tray')).toBeNull()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('Card.MetaItem', () => {
|
|
95
|
+
it('renders digits with tabular-nums', () => {
|
|
96
|
+
// Regression: this used a `tnum` class that is only defined in
|
|
97
|
+
// apps/preview-lab, so in the workspace app every meta figure
|
|
98
|
+
// rendered with proportional digits and jittered on update.
|
|
99
|
+
render(<Card.MetaItem>1,234</Card.MetaItem>)
|
|
100
|
+
expect(screen.getByText('1,234')).toHaveClass('tabular-nums')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the "type-checks, lints, builds, renders nothing"
|
|
3
|
+
* family found by the design-system audit.
|
|
4
|
+
*
|
|
5
|
+
* Every bug pinned here passed typecheck, lint, build AND the full
|
|
6
|
+
* 243-file workspace test suite while shipping visibly broken UI. They
|
|
7
|
+
* share one shape: a declaration that is syntactically fine and
|
|
8
|
+
* semantically void, so no gate that reads the source can see it.
|
|
9
|
+
*
|
|
10
|
+
* These assert the CLASS OF DEFECT, not a specific painted pixel — the
|
|
11
|
+
* suite runs with `css: false`, so a test can't measure colour. What it
|
|
12
|
+
* CAN do is assert that the value handed to the browser is a colour
|
|
13
|
+
* expression and not a box-shadow list, and that a state which is
|
|
14
|
+
* supposed to change the DOM actually changes it. That is enough to
|
|
15
|
+
* catch every one of these coming back.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from 'vitest'
|
|
18
|
+
import { render, screen } from '@testing-library/react'
|
|
19
|
+
import { PillButton } from '../pill-button'
|
|
20
|
+
import { SortableTable } from '../sortable-table'
|
|
21
|
+
import Card from '../card'
|
|
22
|
+
|
|
23
|
+
// The CSS-validity half of this defect class is NOT testable through the
|
|
24
|
+
// DOM — any value containing var() is stored verbatim by the CSSOM, so a
|
|
25
|
+
// rendered assertion passes on the broken form. That half lives in
|
|
26
|
+
// token-shape.test.ts as a source scan. What remains here is the half
|
|
27
|
+
// that IS observable at runtime: state that must change the DOM.
|
|
28
|
+
|
|
29
|
+
describe('PillButton tone map', () => {
|
|
30
|
+
// The map was an inverted copy of Badge's: Badge pairs a tinted `*-bg`
|
|
31
|
+
// fill with solid `*` text, and success/warning/info were copied with
|
|
32
|
+
// bg and color still in Badge's order — painting each label in a
|
|
33
|
+
// 12%-alpha version of its own background. Measured contrast ~1:1.
|
|
34
|
+
it.each(['success', 'warning', 'info', 'accent', 'neutral'] as const)(
|
|
35
|
+
'does not paint a %s label in its own background colour',
|
|
36
|
+
(tone) => {
|
|
37
|
+
render(
|
|
38
|
+
<PillButton active tone={tone} onClick={() => {}}>
|
|
39
|
+
Label
|
|
40
|
+
</PillButton>,
|
|
41
|
+
)
|
|
42
|
+
const btn = screen.getByRole('button', { name: 'Label' })
|
|
43
|
+
const { background, color } = btn.style
|
|
44
|
+
expect(background).not.toBe('')
|
|
45
|
+
expect(color).not.toBe('')
|
|
46
|
+
expect(color).not.toBe(background)
|
|
47
|
+
// The specific inversion: text painted with the fill's -bg token.
|
|
48
|
+
expect(color).not.toMatch(/--(success|warning|info)-bg/)
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
it('keeps the label transparent-free when inactive', () => {
|
|
53
|
+
render(
|
|
54
|
+
<PillButton active={false} onClick={() => {}}>
|
|
55
|
+
Label
|
|
56
|
+
</PillButton>,
|
|
57
|
+
)
|
|
58
|
+
const btn = screen.getByRole('button', { name: 'Label' })
|
|
59
|
+
expect(btn.style.color).not.toBe(btn.style.background)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('SortableTable zebra striping', () => {
|
|
64
|
+
const rows = [
|
|
65
|
+
{ id: 'a', name: 'Alpha' },
|
|
66
|
+
{ id: 'b', name: 'Bravo' },
|
|
67
|
+
{ id: 'c', name: 'Charlie' },
|
|
68
|
+
]
|
|
69
|
+
const columns = [
|
|
70
|
+
{ id: 'name', label: 'Name', accessor: (r: (typeof rows)[number]) => r.name },
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
it('bands alternating rows when zebra is on', () => {
|
|
74
|
+
// NOTE: whether the fill is VALID CSS is checked in token-shape.test.ts
|
|
75
|
+
// — it cannot be checked here (see the file header). This asserts only
|
|
76
|
+
// the banding pattern: odd rows differ from even, and the pattern
|
|
77
|
+
// repeats.
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<SortableTable rows={rows} columns={columns} rowKey={(r) => r.id} zebra />,
|
|
80
|
+
)
|
|
81
|
+
const bodyRows = container.querySelectorAll('tbody tr')
|
|
82
|
+
expect(bodyRows).toHaveLength(3)
|
|
83
|
+
|
|
84
|
+
const backgrounds = Array.from(bodyRows).map(
|
|
85
|
+
(tr) => (tr as HTMLElement).style.background,
|
|
86
|
+
)
|
|
87
|
+
expect(backgrounds[1]).not.toBe('')
|
|
88
|
+
expect(backgrounds[1]).not.toBe(backgrounds[0]!)
|
|
89
|
+
expect(backgrounds[2]).toBe(backgrounds[0]!)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('leaves rows unbanded when zebra is off', () => {
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<SortableTable
|
|
95
|
+
rows={rows}
|
|
96
|
+
columns={columns}
|
|
97
|
+
rowKey={(r) => r.id}
|
|
98
|
+
zebra={false}
|
|
99
|
+
/>,
|
|
100
|
+
)
|
|
101
|
+
const backgrounds = Array.from(
|
|
102
|
+
container.querySelectorAll('tbody tr'),
|
|
103
|
+
).map((tr) => (tr as HTMLElement).style.background)
|
|
104
|
+
expect(new Set(backgrounds).size).toBe(1)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('no interpolated Tailwind classes reach the DOM', () => {
|
|
109
|
+
// Tailwind scans source statically, so `h-[${N}px]` is never generated.
|
|
110
|
+
// Anything matching this shape rendered as a class that does not exist.
|
|
111
|
+
it('Card.MetaItem uses a real utility for tabular figures', () => {
|
|
112
|
+
render(<Card.MetaItem>42</Card.MetaItem>)
|
|
113
|
+
const el = screen.getByText('42')
|
|
114
|
+
expect(el.className).not.toMatch(/\$\{|\[\s*\]/)
|
|
115
|
+
expect(el).toHaveClass('tabular-nums')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input — 130 consuming files, previously zero tests.
|
|
3
|
+
*
|
|
4
|
+
* The sharpest contract here is that Input has TWO class slots:
|
|
5
|
+
* `className` lands on the <input>, `shellClassName` on the wrapper.
|
|
6
|
+
* Consumers get this wrong, and nothing caught it before.
|
|
7
|
+
*/
|
|
8
|
+
import { createRef } from 'react'
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { render, screen } from '@testing-library/react'
|
|
11
|
+
import userEvent from '@testing-library/user-event'
|
|
12
|
+
import { Input } from '../input'
|
|
13
|
+
|
|
14
|
+
const shellOf = (el: HTMLElement) => el.closest('.input-shell')
|
|
15
|
+
|
|
16
|
+
describe('Input', () => {
|
|
17
|
+
it('renders an <input> inside an .input-shell wrapper', () => {
|
|
18
|
+
render(<Input placeholder="Search" />)
|
|
19
|
+
const input = screen.getByPlaceholderText('Search')
|
|
20
|
+
expect(input.tagName).toBe('INPUT')
|
|
21
|
+
expect(shellOf(input)).not.toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('routes className to the <input> and shellClassName to the wrapper', () => {
|
|
25
|
+
render(<Input placeholder="p" className="text-red" shellClassName="w-full" />)
|
|
26
|
+
const input = screen.getByPlaceholderText('p')
|
|
27
|
+
expect(input).toHaveClass('text-red')
|
|
28
|
+
expect(input).not.toHaveClass('w-full')
|
|
29
|
+
expect(shellOf(input)).toHaveClass('input-shell', 'w-full')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('flags .is-error on the shell, not the input', () => {
|
|
33
|
+
render(<Input placeholder="p" error />)
|
|
34
|
+
const input = screen.getByPlaceholderText('p')
|
|
35
|
+
expect(shellOf(input)).toHaveClass('is-error')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('disables the input and flags .is-disabled on the shell', () => {
|
|
39
|
+
render(<Input placeholder="p" disabled />)
|
|
40
|
+
const input = screen.getByPlaceholderText('p')
|
|
41
|
+
expect(input).toBeDisabled()
|
|
42
|
+
expect(shellOf(input)).toHaveClass('is-disabled')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it.each([
|
|
46
|
+
['sm', 'size-sm'],
|
|
47
|
+
['lg', 'size-lg'],
|
|
48
|
+
] as const)('maps inputSize=%s to .%s on the shell', (size, expected) => {
|
|
49
|
+
render(<Input placeholder="p" inputSize={size} />)
|
|
50
|
+
expect(shellOf(screen.getByPlaceholderText('p'))).toHaveClass(expected)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('adds .is-numeric for the tabular calculator variant', () => {
|
|
54
|
+
render(<Input placeholder="p" numeric />)
|
|
55
|
+
expect(shellOf(screen.getByPlaceholderText('p'))).toHaveClass('is-numeric')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('flags has-leading / has-trailing only when the affix is present', () => {
|
|
59
|
+
const { rerender } = render(<Input placeholder="p" />)
|
|
60
|
+
expect(shellOf(screen.getByPlaceholderText('p'))).not.toHaveClass(
|
|
61
|
+
'has-leading',
|
|
62
|
+
'has-trailing',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
rerender(<Input placeholder="p" leadingAffix={<span>$</span>} />)
|
|
66
|
+
const withLeading = shellOf(screen.getByPlaceholderText('p'))
|
|
67
|
+
expect(withLeading).toHaveClass('has-leading')
|
|
68
|
+
expect(withLeading).not.toHaveClass('has-trailing')
|
|
69
|
+
|
|
70
|
+
rerender(
|
|
71
|
+
<Input
|
|
72
|
+
placeholder="p"
|
|
73
|
+
leadingAffix={<span>$</span>}
|
|
74
|
+
trailingAffix={<span>USD</span>}
|
|
75
|
+
/>,
|
|
76
|
+
)
|
|
77
|
+
expect(shellOf(screen.getByPlaceholderText('p'))).toHaveClass(
|
|
78
|
+
'has-leading',
|
|
79
|
+
'has-trailing',
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('forwards a ref to the <input>, not the shell', () => {
|
|
84
|
+
const ref = createRef<HTMLInputElement>()
|
|
85
|
+
render(<Input ref={ref} placeholder="p" />)
|
|
86
|
+
expect(ref.current).toBeInstanceOf(HTMLInputElement)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('spreads rest props onto the <input>', () => {
|
|
90
|
+
render(<Input placeholder="p" aria-label="Email" autoComplete="email" />)
|
|
91
|
+
const input = screen.getByLabelText('Email')
|
|
92
|
+
expect(input).toHaveAttribute('autocomplete', 'email')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('reports typed values through onChange', async () => {
|
|
96
|
+
const onChange = vi.fn()
|
|
97
|
+
render(<Input placeholder="p" onChange={onChange} />)
|
|
98
|
+
await userEvent.type(screen.getByPlaceholderText('p'), 'ab')
|
|
99
|
+
expect(onChange).toHaveBeenCalledTimes(2)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('password toggle', () => {
|
|
103
|
+
it('reveals and re-hides the value', async () => {
|
|
104
|
+
render(<Input placeholder="p" type="password" showPasswordToggle />)
|
|
105
|
+
const input = screen.getByPlaceholderText('p')
|
|
106
|
+
expect(input).toHaveAttribute('type', 'password')
|
|
107
|
+
|
|
108
|
+
const toggle = screen.getByRole('button', { name: 'Show password' })
|
|
109
|
+
await userEvent.click(toggle)
|
|
110
|
+
expect(input).toHaveAttribute('type', 'text')
|
|
111
|
+
|
|
112
|
+
await userEvent.click(screen.getByRole('button', { name: 'Hide password' }))
|
|
113
|
+
expect(input).toHaveAttribute('type', 'password')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('is a no-op unless type="password"', () => {
|
|
117
|
+
render(<Input placeholder="p" type="text" showPasswordToggle />)
|
|
118
|
+
expect(screen.queryByRole('button', { name: 'Show password' })).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('yields to a caller-supplied trailingAffix', () => {
|
|
122
|
+
render(
|
|
123
|
+
<Input
|
|
124
|
+
placeholder="p"
|
|
125
|
+
type="password"
|
|
126
|
+
showPasswordToggle
|
|
127
|
+
trailingAffix={<span data-testid="mine" />}
|
|
128
|
+
/>,
|
|
129
|
+
)
|
|
130
|
+
expect(screen.getByTestId('mine')).toBeInTheDocument()
|
|
131
|
+
expect(screen.queryByRole('button', { name: 'Show password' })).toBeNull()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
})
|