@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
//
|
|
3
|
+
// Pure source scan — no DOM needed, and jsdom rewrites import.meta.url to
|
|
4
|
+
// an http URL that fileURLToPath rejects.
|
|
5
|
+
/**
|
|
6
|
+
* Static guard for the "type-checks, lints, builds, renders nothing"
|
|
7
|
+
* defect class — CSS that is syntactically fine and semantically void.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS IS A SOURCE SCAN AND NOT A RENDER TEST
|
|
10
|
+
* ------------------------------------------------
|
|
11
|
+
* The first attempt at this asserted rendered inline styles. It could not
|
|
12
|
+
* work: any value containing `var()` is "pending substitution", so jsdom
|
|
13
|
+
* (and browsers, at parse time) store it verbatim without validating it.
|
|
14
|
+
* `rgb(var(--surface-overlay-soft) / 0.5)` and `rgb(var(--muted))` are
|
|
15
|
+
* indistinguishable through the CSSOM — a render test happily passes on
|
|
16
|
+
* the broken form. The invalidity only appears at computed-value time in
|
|
17
|
+
* a real browser, which is precisely why typecheck, lint, build and the
|
|
18
|
+
* whole test suite were all green while three primitives shipped with no
|
|
19
|
+
* error border and every SortableTable shipped unbanded.
|
|
20
|
+
*
|
|
21
|
+
* So this reads the source instead, and derives its rules from
|
|
22
|
+
* tokens.css rather than hardcoding a token list — a new alpha-carrying
|
|
23
|
+
* or shadow-valued token is covered the day it is added.
|
|
24
|
+
*
|
|
25
|
+
* The two rules:
|
|
26
|
+
*
|
|
27
|
+
* 1. A token whose VALUE is a shadow list ("0 0 0 3px rgb(...)") can
|
|
28
|
+
* never appear inside rgb(). `rgb(var(--ring-error))` expands to
|
|
29
|
+
* `rgb(0 0 0 3px rgb(...))`, which is dropped.
|
|
30
|
+
*
|
|
31
|
+
* 2. A token whose VALUE already carries an alpha ("0 0 0 / 0.03")
|
|
32
|
+
* can never take a second one. `rgb(var(--surface-overlay-soft) / 0.5)`
|
|
33
|
+
* expands to `rgb(0 0 0 / 0.03 / 0.5)`, which is dropped.
|
|
34
|
+
*/
|
|
35
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
36
|
+
import { join, relative } from 'node:path'
|
|
37
|
+
import { fileURLToPath } from 'node:url'
|
|
38
|
+
import { describe, expect, it } from 'vitest'
|
|
39
|
+
|
|
40
|
+
const SRC = fileURLToPath(new URL('..', import.meta.url))
|
|
41
|
+
|
|
42
|
+
function sourceFiles(dir: string, acc: string[] = []): string[] {
|
|
43
|
+
for (const entry of readdirSync(dir)) {
|
|
44
|
+
if (entry === 'node_modules' || entry === '__tests__') continue
|
|
45
|
+
const full = join(dir, entry)
|
|
46
|
+
if (statSync(full).isDirectory()) sourceFiles(full, acc)
|
|
47
|
+
else if (/\.(tsx?|css)$/.test(entry) && entry !== 'tokens.css') acc.push(full)
|
|
48
|
+
}
|
|
49
|
+
return acc
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse tokens.css into { name -> [values across all theme blocks] }. */
|
|
53
|
+
function readTokenValues(): Map<string, string[]> {
|
|
54
|
+
const css = readFileSync(join(SRC, 'tokens.css'), 'utf8')
|
|
55
|
+
const out = new Map<string, string[]>()
|
|
56
|
+
for (const [, name, value] of css.matchAll(/^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gm)) {
|
|
57
|
+
const list = out.get(name!) ?? []
|
|
58
|
+
list.push(value!.trim())
|
|
59
|
+
out.set(name!, list)
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const TOKENS = readTokenValues()
|
|
65
|
+
|
|
66
|
+
/** A shadow list — has a length unit, so it is not a colour. */
|
|
67
|
+
const isShadowValued = (values: string[]) =>
|
|
68
|
+
values.some((v) => /\b\d+(px|rem|em)\b/.test(v) || v.startsWith('inset '))
|
|
69
|
+
|
|
70
|
+
/** Already carries an alpha, so it cannot take a second one. */
|
|
71
|
+
const isAlphaCarrying = (values: string[]) =>
|
|
72
|
+
values.some((v) => /^[\d\s.]+\/\s*[\d.]+$/.test(v))
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Blank out comments while preserving line numbers, so a violation still
|
|
76
|
+
* reports the line it is on. Without this the scan flags its own
|
|
77
|
+
* explanatory comments — every doc-block that spells out the broken form
|
|
78
|
+
* in order to warn against it.
|
|
79
|
+
*
|
|
80
|
+
* Line comments are only stripped when the line STARTS with `//`, so a
|
|
81
|
+
* `https://` inside a string is left intact.
|
|
82
|
+
*/
|
|
83
|
+
function stripComments(src: string): string {
|
|
84
|
+
return src
|
|
85
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
|
86
|
+
.split('\n')
|
|
87
|
+
.map((line) => (/^\s*\/\//.test(line) ? '' : line))
|
|
88
|
+
.join('\n')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const FILES = sourceFiles(SRC)
|
|
92
|
+
const rel = (f: string) => relative(SRC, f)
|
|
93
|
+
const readCode = (f: string) => stripComments(readFileSync(f, 'utf8')).split('\n')
|
|
94
|
+
|
|
95
|
+
describe('token shape', () => {
|
|
96
|
+
it('parsed a plausible token file', () => {
|
|
97
|
+
// Guard the guard: if the parse silently returned nothing, every
|
|
98
|
+
// assertion below would vacuously pass.
|
|
99
|
+
expect(TOKENS.size).toBeGreaterThan(100)
|
|
100
|
+
expect(isShadowValued(TOKENS.get('--ring-error') ?? [])).toBe(true)
|
|
101
|
+
expect(isAlphaCarrying(TOKENS.get('--surface-overlay-soft') ?? [])).toBe(true)
|
|
102
|
+
expect(isShadowValued(TOKENS.get('--accent') ?? [])).toBe(false)
|
|
103
|
+
expect(isAlphaCarrying(TOKENS.get('--accent') ?? [])).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('finds source files to scan', () => {
|
|
107
|
+
expect(FILES.length).toBeGreaterThan(50)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('never wraps a shadow-valued token in rgb()', () => {
|
|
111
|
+
const violations: string[] = []
|
|
112
|
+
for (const file of FILES) {
|
|
113
|
+
readCode(file).forEach((line, i) => {
|
|
114
|
+
for (const [, token] of line.matchAll(/rgb\(\s*var\((--[a-z0-9-]+)\)/g)) {
|
|
115
|
+
if (isShadowValued(TOKENS.get(token!) ?? [])) {
|
|
116
|
+
violations.push(`${rel(file)}:${i + 1} — rgb(var(${token})) is a box-shadow list, not a colour`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
expect(violations).toEqual([])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('never nests a second alpha on an alpha-carrying token', () => {
|
|
125
|
+
const violations: string[] = []
|
|
126
|
+
for (const file of FILES) {
|
|
127
|
+
readCode(file).forEach((line, i) => {
|
|
128
|
+
for (const [, token] of line.matchAll(
|
|
129
|
+
/rgb\(\s*var\((--[a-z0-9-]+)\)\s*\/\s*[\d.]/g,
|
|
130
|
+
)) {
|
|
131
|
+
if (isAlphaCarrying(TOKENS.get(token!) ?? [])) {
|
|
132
|
+
violations.push(`${rel(file)}:${i + 1} — var(${token}) already carries an alpha`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
expect(violations).toEqual([])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('never references a token that tokens.css does not define', () => {
|
|
141
|
+
const violations: string[] = []
|
|
142
|
+
for (const file of FILES) {
|
|
143
|
+
readCode(file).forEach((line, i) => {
|
|
144
|
+
for (const [, token] of line.matchAll(/var\((--[a-z0-9-]+)[,)]/g)) {
|
|
145
|
+
// Tailwind's own custom properties are not ours to define.
|
|
146
|
+
if (token!.startsWith('--tw-')) continue
|
|
147
|
+
if (!TOKENS.has(token!)) {
|
|
148
|
+
violations.push(`${rel(file)}:${i + 1} — var(${token}) is not defined in tokens.css`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
expect(violations).toEqual([])
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('interpolated Tailwind classes', () => {
|
|
158
|
+
it('never builds an arbitrary-value utility from a template literal', () => {
|
|
159
|
+
// Tailwind scans source statically, so `h-[${SIZE}px]` is never
|
|
160
|
+
// generated. It type-checks, lints and builds, and does nothing.
|
|
161
|
+
const violations: string[] = []
|
|
162
|
+
for (const file of FILES) {
|
|
163
|
+
readCode(file).forEach((line, i) => {
|
|
164
|
+
if (/[a-z-]+-\[[^\]]*\$\{/.test(line)) {
|
|
165
|
+
violations.push(`${rel(file)}:${i + 1} — ${line.trim()}`)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
expect(violations).toEqual([])
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('undefined utility classes', () => {
|
|
174
|
+
// `animate-in`, `fade-in-0`, `zoom-in-95`, `slide-in-from-*` ship in
|
|
175
|
+
// tailwindcss-animate / tw-animate-css. Neither is a dependency of this
|
|
176
|
+
// repo, and Tailwind 4 core ships only spin/ping/pulse/bounce — so both
|
|
177
|
+
// of the kit's entrance animations referenced classes that had never
|
|
178
|
+
// been generated and had never run.
|
|
179
|
+
const ANIMATE_PLUGIN_CLASSES =
|
|
180
|
+
/\b(animate-in|animate-out|fade-in(-\d+)?|fade-out(-\d+)?|zoom-in(-\d+)?|zoom-out(-\d+)?|slide-in-from-\w+(-\d+)?|slide-out-to-\w+(-\d+)?)\b/
|
|
181
|
+
|
|
182
|
+
it('does not use tailwindcss-animate classes without the dependency', () => {
|
|
183
|
+
const violations: string[] = []
|
|
184
|
+
for (const file of FILES) {
|
|
185
|
+
readCode(file).forEach((line, i) => {
|
|
186
|
+
if (ANIMATE_PLUGIN_CLASSES.test(line)) {
|
|
187
|
+
violations.push(`${rel(file)}:${i + 1} — ${line.trim()}`)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
expect(violations).toEqual([])
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AllocationSparkbar — a compact per-period bar strip.
|
|
3
|
+
*
|
|
4
|
+
* New in ADR-123 (Budget Flighting redesign). Renders one bar per period
|
|
5
|
+
* (month), height proportional to the value; a zero/paused period renders
|
|
6
|
+
* short + dashed in a muted tone. Each bar can tint from its own token (e.g. a
|
|
7
|
+
* flight-group color), falling back to `baseColorVar`. Optional single-letter
|
|
8
|
+
* labels sit under each bar.
|
|
9
|
+
*
|
|
10
|
+
* Token discipline: colors come only from public token *names* via
|
|
11
|
+
* `rgb(var(--token) / a)`. No literals. Layout sizes use the spacing scale.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface AllocationSparkbarProps {
|
|
15
|
+
/** Per-period amounts. A value of 0 is treated as paused (short + dashed). */
|
|
16
|
+
values: number[]
|
|
17
|
+
/** Base token name for normal bars, e.g. `--folder-teal`. Includes `--`. */
|
|
18
|
+
baseColorVar: string
|
|
19
|
+
/** Optional per-period token name (e.g. a flight-group color). `null`/
|
|
20
|
+
* `undefined` falls back to `baseColorVar`. Length should match `values`. */
|
|
21
|
+
colorVars?: (string | null | undefined)[]
|
|
22
|
+
/** Optional short labels under each bar (e.g. `['J','F','M']`). */
|
|
23
|
+
labels?: string[]
|
|
24
|
+
/** Optional per-bar tooltip text (native `title`). */
|
|
25
|
+
titles?: string[]
|
|
26
|
+
/** Strip height in px. Default 36. */
|
|
27
|
+
height?: number
|
|
28
|
+
className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function AllocationSparkbar({
|
|
32
|
+
values,
|
|
33
|
+
baseColorVar,
|
|
34
|
+
colorVars,
|
|
35
|
+
labels,
|
|
36
|
+
titles,
|
|
37
|
+
height = 36,
|
|
38
|
+
className,
|
|
39
|
+
}: AllocationSparkbarProps) {
|
|
40
|
+
const max = Math.max(...values, 1)
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className={className}
|
|
44
|
+
style={{ display: 'flex', gap: 'var(--space-1)', alignItems: 'flex-end', height }}
|
|
45
|
+
>
|
|
46
|
+
{values.map((value, i) => {
|
|
47
|
+
const paused = value === 0
|
|
48
|
+
const barH = paused ? 4 : 8 + (value / max) * (height - 12)
|
|
49
|
+
const colorVar = colorVars?.[i] ?? baseColorVar
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
key={i}
|
|
53
|
+
title={titles?.[i]}
|
|
54
|
+
style={{
|
|
55
|
+
flex: 1,
|
|
56
|
+
display: 'flex',
|
|
57
|
+
flexDirection: 'column',
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
gap: 'var(--space-1)',
|
|
60
|
+
minWidth: 0,
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<div
|
|
64
|
+
style={{
|
|
65
|
+
width: '100%',
|
|
66
|
+
height: barH,
|
|
67
|
+
borderRadius: 'var(--radius-xs)',
|
|
68
|
+
background: paused
|
|
69
|
+
? 'rgb(var(--text-muted) / 0.18)'
|
|
70
|
+
: `rgb(var(${colorVar}) / 0.67)`,
|
|
71
|
+
border: paused
|
|
72
|
+
? '1px dashed rgb(var(--text-muted) / 0.4)'
|
|
73
|
+
: 'none',
|
|
74
|
+
transition: 'background var(--dur-fast, 120ms)',
|
|
75
|
+
}}
|
|
76
|
+
/>
|
|
77
|
+
{labels?.[i] !== undefined && (
|
|
78
|
+
<span
|
|
79
|
+
className="text-[9.5px] font-semibold tracking-[0.02em]"
|
|
80
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
81
|
+
>
|
|
82
|
+
{labels[i]}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|