@lovett/ui 0.0.5 → 0.0.7

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/dist/index.d.ts +279 -115
  2. package/dist/index.js +479 -66
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +74 -0
  5. package/dist/tokens.css +181 -60
  6. package/package.json +16 -4
  7. package/src/__tests__/button.test.tsx +137 -0
  8. package/src/__tests__/card.test.tsx +103 -0
  9. package/src/__tests__/dead-render.test.tsx +117 -0
  10. package/src/__tests__/input.test.tsx +134 -0
  11. package/src/__tests__/modal.test.tsx +154 -0
  12. package/src/__tests__/page-shell.test.tsx +128 -0
  13. package/src/__tests__/setup.ts +43 -0
  14. package/src/__tests__/token-shape.test.ts +193 -0
  15. package/src/card.tsx +1 -1
  16. package/src/collapsible-card.tsx +85 -0
  17. package/src/data-grid/table-body.tsx +8 -1
  18. package/src/dropdown-menu.tsx +1 -1
  19. package/src/floating-status-bar.tsx +1 -1
  20. package/src/folder-tree-picker.tsx +5 -6
  21. package/src/frame-stack.tsx +27 -10
  22. package/src/hero-form-card.tsx +2 -2
  23. package/src/icons/brand.tsx +187 -0
  24. package/src/index.ts +32 -0
  25. package/src/lib/clipboard.ts +14 -0
  26. package/src/lib/color.ts +111 -0
  27. package/src/meta-cell.tsx +52 -0
  28. package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
  29. package/src/meta-previews/MetaFeedPreview.tsx +1 -1
  30. package/src/modal.tsx +77 -6
  31. package/src/pill-button.tsx +23 -5
  32. package/src/profile-section.tsx +40 -9
  33. package/src/sortable-table.tsx +5 -1
  34. package/src/styles.css +74 -0
  35. package/src/tabs.tsx +4 -0
  36. package/src/tag-chip-input.tsx +1 -1
  37. package/src/theme-v2.css +466 -0
  38. package/src/tokens.css +181 -60
  39. package/src/v2/README.md +208 -0
  40. package/src/v2/__demo__/showcase.tsx +1045 -0
  41. package/src/v2/action.tsx +91 -0
  42. package/src/v2/callout.tsx +76 -0
  43. package/src/v2/document-section.tsx +82 -0
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +113 -0
  46. package/src/v2/icons.tsx +165 -0
  47. package/src/v2/index.ts +147 -0
  48. package/src/v2/layout.tsx +293 -0
  49. package/src/v2/progress-track.tsx +89 -0
  50. package/src/v2/stat-tile.tsx +129 -0
  51. package/src/v2/states.tsx +271 -0
  52. package/src/v2/status-pill.tsx +74 -0
  53. package/src/v2/theme.css +1861 -0
  54. package/src/v2/timeline.tsx +81 -0
  55. package/src/v2/tokens.ts +228 -0
  56. package/src/value-chip.tsx +76 -0
@@ -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
+ })
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Modal — 51 consuming files plus 113 sub-component call sites.
3
+ *
4
+ * Note what these tests deliberately do NOT claim: Modal has no
5
+ * role="dialog", no aria-modal, no focus trap and no focus restore, so
6
+ * there is no dialog role to query by. That is a real accessibility gap
7
+ * (a keyboard user can Tab through the page behind the backdrop) and it
8
+ * is tracked separately — these tests pin the behaviour that exists so
9
+ * the a11y work can be verified as a change, not guessed at.
10
+ */
11
+ import { describe, expect, it, vi } from 'vitest'
12
+ import { render, screen } from '@testing-library/react'
13
+ import userEvent from '@testing-library/user-event'
14
+ import Modal from '../modal'
15
+
16
+ const noop = () => {}
17
+
18
+ describe('Modal', () => {
19
+ it('renders nothing when closed', () => {
20
+ const { container } = render(
21
+ <Modal isOpen={false} onClose={noop} title="Confirm">
22
+ body
23
+ </Modal>,
24
+ )
25
+ expect(container).toBeEmptyDOMElement()
26
+ })
27
+
28
+ it('renders the title and body when open', () => {
29
+ render(
30
+ <Modal isOpen onClose={noop} title="Confirm">
31
+ body content
32
+ </Modal>,
33
+ )
34
+ expect(screen.getByRole('heading', { name: 'Confirm' })).toBeInTheDocument()
35
+ expect(screen.getByText('body content')).toBeInTheDocument()
36
+ })
37
+
38
+ it('closes on the labelled close button', async () => {
39
+ const onClose = vi.fn()
40
+ render(
41
+ <Modal isOpen onClose={onClose} title="Confirm">
42
+ body
43
+ </Modal>,
44
+ )
45
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }))
46
+ expect(onClose).toHaveBeenCalledTimes(1)
47
+ })
48
+
49
+ it('closes on Escape', async () => {
50
+ const onClose = vi.fn()
51
+ render(
52
+ <Modal isOpen onClose={onClose} title="Confirm">
53
+ body
54
+ </Modal>,
55
+ )
56
+ await userEvent.keyboard('{Escape}')
57
+ expect(onClose).toHaveBeenCalledTimes(1)
58
+ })
59
+
60
+ it('does not listen for Escape while closed', async () => {
61
+ const onClose = vi.fn()
62
+ render(
63
+ <Modal isOpen={false} onClose={onClose} title="Confirm">
64
+ body
65
+ </Modal>,
66
+ )
67
+ await userEvent.keyboard('{Escape}')
68
+ expect(onClose).not.toHaveBeenCalled()
69
+ })
70
+
71
+ it('detaches the Escape listener on unmount', async () => {
72
+ const onClose = vi.fn()
73
+ const { unmount } = render(
74
+ <Modal isOpen onClose={onClose} title="Confirm">
75
+ body
76
+ </Modal>,
77
+ )
78
+ unmount()
79
+ await userEvent.keyboard('{Escape}')
80
+ expect(onClose).not.toHaveBeenCalled()
81
+ })
82
+
83
+ describe('Tray + Footer compose into one decision surface', () => {
84
+ // The actions belong ON the tray, not in the frame gap below it.
85
+ // Before this, Tray sat at --tray-inset (4px) and Footer on the outer
86
+ // card at px-6 (24px), so the buttons were 20px inboard of the tray
87
+ // edge they were meant to align with, and `.ds-card-tray:not(:last-child)`
88
+ // zeroed the tray's bottom margin so its lower corners were cut flush.
89
+ const renderBoth = () =>
90
+ render(
91
+ <Modal isOpen onClose={noop} title="Confirm">
92
+ <Modal.Tray>tray body</Modal.Tray>
93
+ <Modal.Footer>
94
+ <button type="button">Confirm</button>
95
+ </Modal.Footer>
96
+ </Modal>,
97
+ )
98
+
99
+ it('puts the actions INSIDE the tray', () => {
100
+ renderBoth()
101
+ const tray = document.querySelector('.ds-card-tray')
102
+ expect(tray).toHaveTextContent('tray body')
103
+ expect(tray).toHaveTextContent('Confirm')
104
+ expect(tray!.contains(screen.getByRole('button', { name: 'Confirm' }))).toBe(
105
+ true,
106
+ )
107
+ })
108
+
109
+ it('renders exactly one tray — the footer does not get its own', () => {
110
+ renderBoth()
111
+ expect(document.querySelectorAll('.ds-card-tray')).toHaveLength(1)
112
+ })
113
+
114
+ it('separates body from actions with a divider', () => {
115
+ renderBoth()
116
+ const footer = screen
117
+ .getByRole('button', { name: 'Confirm' })
118
+ .closest('div')!
119
+ expect(footer.className).toContain('border-t')
120
+ })
121
+
122
+ it('keeps a Tray standalone when there is no Footer', () => {
123
+ render(
124
+ <Modal isOpen onClose={noop} title="Confirm">
125
+ <Modal.Tray>tray body</Modal.Tray>
126
+ </Modal>,
127
+ )
128
+ const tray = document.querySelector('.ds-card-tray')
129
+ expect(tray).toHaveTextContent('tray body')
130
+ // The standalone tray owns its own padding.
131
+ expect(tray!.className).toContain('p-5')
132
+ })
133
+
134
+ it('leaves flat children alone', () => {
135
+ render(
136
+ <Modal isOpen onClose={noop} title="Confirm">
137
+ <p>flat body</p>
138
+ </Modal>,
139
+ )
140
+ expect(screen.getByText('flat body')).toBeInTheDocument()
141
+ expect(document.querySelector('.ds-card-tray')).toBeNull()
142
+ })
143
+ })
144
+
145
+ it('merges a caller className onto the panel', () => {
146
+ render(
147
+ <Modal isOpen onClose={noop} title="Confirm" className="max-w-sm">
148
+ body
149
+ </Modal>,
150
+ )
151
+ const panel = document.querySelector('.ds-card-surface')
152
+ expect(panel).toHaveClass('ds-card-surface', 'max-w-sm')
153
+ })
154
+ })
@@ -0,0 +1,128 @@
1
+ /**
2
+ * PageShell — 84 consuming files.
3
+ *
4
+ * Two behaviours matter to consumers and neither was verified before:
5
+ * the legacy `breadcrumb` + `title` props still resolve to the same
6
+ * crumb chain as `crumbs`, and the slot-host mode relocates the header
7
+ * instead of rendering a second sticky bar.
8
+ *
9
+ * PageShell imports <Link>, which is why every consumer inherits a hard
10
+ * dependency on react-router-dom — hence the MemoryRouter here.
11
+ */
12
+ import { describe, expect, it } from 'vitest'
13
+ import { render, screen } from '@testing-library/react'
14
+ import { MemoryRouter } from 'react-router-dom'
15
+ import {
16
+ PageShell,
17
+ PageHeaderSlotProvider,
18
+ PageHeaderHost,
19
+ } from '../page-shell'
20
+
21
+ const renderShell = (ui: React.ReactNode) =>
22
+ render(<MemoryRouter>{ui}</MemoryRouter>)
23
+
24
+ describe('PageShell', () => {
25
+ it('renders its children', () => {
26
+ renderShell(
27
+ <PageShell title="Brand">
28
+ <p>lens content</p>
29
+ </PageShell>,
30
+ )
31
+ expect(screen.getByText('lens content')).toBeInTheDocument()
32
+ })
33
+
34
+ it('renders a crumb chain, linking only the crumbs that have a `to`', () => {
35
+ renderShell(
36
+ <PageShell
37
+ crumbs={[
38
+ { label: 'Clients', to: '/clients' },
39
+ { label: 'Acme' },
40
+ { label: 'Brand' },
41
+ ]}
42
+ >
43
+ <p>x</p>
44
+ </PageShell>,
45
+ )
46
+ expect(screen.getByRole('link', { name: 'Clients' })).toHaveAttribute(
47
+ 'href',
48
+ '/clients',
49
+ )
50
+ expect(screen.queryByRole('link', { name: 'Acme' })).toBeNull()
51
+ expect(screen.getByText('Acme')).toBeInTheDocument()
52
+ expect(screen.getByText('Brand')).toBeInTheDocument()
53
+ })
54
+
55
+ it('resolves the legacy breadcrumb + title pair into the same chain', () => {
56
+ renderShell(
57
+ <PageShell breadcrumb="Clients" title="Brand">
58
+ <p>x</p>
59
+ </PageShell>,
60
+ )
61
+ expect(screen.getByText('Clients')).toBeInTheDocument()
62
+ expect(screen.getByText('Brand')).toBeInTheDocument()
63
+ })
64
+
65
+ it('prefers `crumbs` over the legacy props when both are supplied', () => {
66
+ renderShell(
67
+ <PageShell breadcrumb="Legacy" title="Old" crumbs={[{ label: 'New' }]}>
68
+ <p>x</p>
69
+ </PageShell>,
70
+ )
71
+ expect(screen.getByText('New')).toBeInTheDocument()
72
+ expect(screen.queryByText('Legacy')).toBeNull()
73
+ expect(screen.queryByText('Old')).toBeNull()
74
+ })
75
+
76
+ it('renders actions', () => {
77
+ renderShell(
78
+ <PageShell title="Brand" actions={<button type="button">Export</button>}>
79
+ <p>x</p>
80
+ </PageShell>,
81
+ )
82
+ expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument()
83
+ })
84
+
85
+ it('renders its own sticky header when no host is mounted', () => {
86
+ renderShell(
87
+ <PageShell title="Brand">
88
+ <p>x</p>
89
+ </PageShell>,
90
+ )
91
+ expect(screen.getByRole('banner')).toBeInTheDocument()
92
+ })
93
+
94
+ it('portals the header into the host instead of rendering a second bar', () => {
95
+ renderShell(
96
+ <PageHeaderSlotProvider>
97
+ <PageHeaderHost />
98
+ <PageShell title="Brand" actions={<button type="button">Export</button>}>
99
+ <p>lens content</p>
100
+ </PageShell>
101
+ </PageHeaderSlotProvider>,
102
+ )
103
+ // The crumbs and actions still render exactly once...
104
+ expect(screen.getByText('Brand')).toBeInTheDocument()
105
+ expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument()
106
+ // ...and PageShell does not add its own <header> on top of the host's.
107
+ expect(screen.getAllByRole('banner')).toHaveLength(1)
108
+ })
109
+
110
+ it('applies the default content padding, overridable by contentClassName', () => {
111
+ const { container, rerender } = renderShell(
112
+ <PageShell title="Brand">
113
+ <p>x</p>
114
+ </PageShell>,
115
+ )
116
+ expect(container.querySelector('.px-7')).not.toBeNull()
117
+
118
+ rerender(
119
+ <MemoryRouter>
120
+ <PageShell title="Brand" contentClassName="p-0">
121
+ <p>x</p>
122
+ </PageShell>
123
+ </MemoryRouter>,
124
+ )
125
+ expect(container.querySelector('.px-7')).toBeNull()
126
+ expect(container.querySelector('.p-0')).not.toBeNull()
127
+ })
128
+ })
@@ -0,0 +1,43 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+
3
+ // Setup runs for BOTH environments. Pure source-scan files opt into the
4
+ // node env with a `// @vitest-environment node` pragma (it skips the
5
+ // ~500ms jsdom init), so every DOM stub below has to be guarded.
6
+ const HAS_DOM = typeof window !== 'undefined'
7
+
8
+ if (HAS_DOM) {
9
+ // jsdom ships neither observer. DropdownMenu measures its content before
10
+ // revealing it, ChipNav scroll-spies with an IntersectionObserver, and
11
+ // several primitives observe container resize. No-op stubs are enough
12
+ // for tests that don't exercise the observers themselves.
13
+ class StubIntersectionObserver {
14
+ observe() {}
15
+ unobserve() {}
16
+ disconnect() {}
17
+ takeRecords(): IntersectionObserverEntry[] {
18
+ return []
19
+ }
20
+ root = null
21
+ rootMargin = ''
22
+ thresholds = []
23
+ }
24
+ ;(
25
+ globalThis as unknown as {
26
+ IntersectionObserver: typeof IntersectionObserver
27
+ }
28
+ ).IntersectionObserver =
29
+ StubIntersectionObserver as unknown as typeof IntersectionObserver
30
+
31
+ class StubResizeObserver {
32
+ observe() {}
33
+ unobserve() {}
34
+ disconnect() {}
35
+ }
36
+ ;(
37
+ globalThis as unknown as { ResizeObserver: typeof ResizeObserver }
38
+ ).ResizeObserver = StubResizeObserver as unknown as typeof ResizeObserver
39
+
40
+ if (!Element.prototype.scrollIntoView) {
41
+ Element.prototype.scrollIntoView = function () {}
42
+ }
43
+ }