@lovett/ui 0.0.11 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabs — the WAI-ARIA APG keyboard contract.
|
|
3
|
+
*
|
|
4
|
+
* `role="tablist"` shipped with none of the behaviour the role promises: no
|
|
5
|
+
* roving tabindex (every tab was its own tab stop), no arrow keys, and no
|
|
6
|
+
* `id` / `aria-controls` linking a tab to anything. These pin the four halves
|
|
7
|
+
* of the fix — movement + wrap, Home/End, the roving tabindex values, and
|
|
8
|
+
* that an emitted `aria-controls` actually resolves to a rendered element.
|
|
9
|
+
*/
|
|
10
|
+
import { useState } from 'react'
|
|
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 { Tabs, type Tab } from '../tabs'
|
|
15
|
+
import { SegmentedChoice } from '../detail/segmented-choice'
|
|
16
|
+
|
|
17
|
+
const TABS: Tab[] = [
|
|
18
|
+
{ id: 'overview', label: 'Overview' },
|
|
19
|
+
{ id: 'activity', label: 'Activity' },
|
|
20
|
+
{ id: 'settings', label: 'Settings' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/** Controlled harness — the component is controlled, so the test owns state. */
|
|
24
|
+
function Harness({
|
|
25
|
+
tabs = TABS,
|
|
26
|
+
initial = 'overview',
|
|
27
|
+
variant,
|
|
28
|
+
onTabChange,
|
|
29
|
+
}: {
|
|
30
|
+
tabs?: Tab[]
|
|
31
|
+
initial?: string
|
|
32
|
+
variant?: 'underline' | 'pill' | 'segmented'
|
|
33
|
+
onTabChange?: (id: string) => void
|
|
34
|
+
}) {
|
|
35
|
+
const [active, setActive] = useState(initial)
|
|
36
|
+
return (
|
|
37
|
+
<Tabs
|
|
38
|
+
idPrefix="demo"
|
|
39
|
+
{...(variant === undefined ? {} : { variant })}
|
|
40
|
+
tabs={tabs}
|
|
41
|
+
activeTabId={active}
|
|
42
|
+
onTabChange={(id) => {
|
|
43
|
+
setActive(id)
|
|
44
|
+
onTabChange?.(id)
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const tab = (name: string) => screen.getByRole('tab', { name })
|
|
51
|
+
|
|
52
|
+
describe('Tabs', () => {
|
|
53
|
+
describe('roving tabindex', () => {
|
|
54
|
+
it('puts exactly one tab in the tab ring, and it is the selected one', () => {
|
|
55
|
+
render(<Harness initial="activity" />)
|
|
56
|
+
expect(tab('Overview')).toHaveAttribute('tabindex', '-1')
|
|
57
|
+
expect(tab('Activity')).toHaveAttribute('tabindex', '0')
|
|
58
|
+
expect(tab('Settings')).toHaveAttribute('tabindex', '-1')
|
|
59
|
+
expect(
|
|
60
|
+
screen.getAllByRole('tab').filter((node) => node.getAttribute('tabindex') === '0'),
|
|
61
|
+
).toHaveLength(1)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('never emits a positive tabindex', () => {
|
|
65
|
+
render(<Harness />)
|
|
66
|
+
for (const node of screen.getAllByRole('tab')) {
|
|
67
|
+
expect(Number(node.getAttribute('tabindex'))).toBeLessThanOrEqual(0)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('falls back to the first enabled tab when the selected one is disabled', () => {
|
|
72
|
+
render(
|
|
73
|
+
<Harness
|
|
74
|
+
initial="overview"
|
|
75
|
+
tabs={[
|
|
76
|
+
{ id: 'overview', label: 'Overview', disabled: true },
|
|
77
|
+
{ id: 'activity', label: 'Activity' },
|
|
78
|
+
]}
|
|
79
|
+
/>,
|
|
80
|
+
)
|
|
81
|
+
expect(tab('Activity')).toHaveAttribute('tabindex', '0')
|
|
82
|
+
expect(tab('Overview')).toHaveAttribute('tabindex', '-1')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('moves the tab stop with the selection', async () => {
|
|
86
|
+
render(<Harness />)
|
|
87
|
+
tab('Overview').focus()
|
|
88
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
89
|
+
expect(tab('Activity')).toHaveAttribute('tabindex', '0')
|
|
90
|
+
expect(tab('Overview')).toHaveAttribute('tabindex', '-1')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('arrow keys', () => {
|
|
95
|
+
it('ArrowRight moves focus and selects the next tab (automatic activation)', async () => {
|
|
96
|
+
const onTabChange = vi.fn()
|
|
97
|
+
render(<Harness onTabChange={onTabChange} />)
|
|
98
|
+
tab('Overview').focus()
|
|
99
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
100
|
+
expect(tab('Activity')).toHaveFocus()
|
|
101
|
+
expect(tab('Activity')).toHaveAttribute('aria-selected', 'true')
|
|
102
|
+
expect(onTabChange).toHaveBeenCalledWith('activity')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('ArrowLeft moves the other way', async () => {
|
|
106
|
+
render(<Harness initial="settings" />)
|
|
107
|
+
tab('Settings').focus()
|
|
108
|
+
await userEvent.keyboard('{ArrowLeft}')
|
|
109
|
+
expect(tab('Activity')).toHaveFocus()
|
|
110
|
+
expect(tab('Activity')).toHaveAttribute('aria-selected', 'true')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('wraps past the end', async () => {
|
|
114
|
+
render(<Harness initial="settings" />)
|
|
115
|
+
tab('Settings').focus()
|
|
116
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
117
|
+
expect(tab('Overview')).toHaveFocus()
|
|
118
|
+
expect(tab('Overview')).toHaveAttribute('aria-selected', 'true')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('wraps past the start', async () => {
|
|
122
|
+
render(<Harness />)
|
|
123
|
+
tab('Overview').focus()
|
|
124
|
+
await userEvent.keyboard('{ArrowLeft}')
|
|
125
|
+
expect(tab('Settings')).toHaveFocus()
|
|
126
|
+
expect(tab('Settings')).toHaveAttribute('aria-selected', 'true')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('steps over a disabled tab rather than stranding focus on it', async () => {
|
|
130
|
+
render(
|
|
131
|
+
<Harness
|
|
132
|
+
tabs={[
|
|
133
|
+
{ id: 'overview', label: 'Overview' },
|
|
134
|
+
{ id: 'activity', label: 'Activity', disabled: true },
|
|
135
|
+
{ id: 'settings', label: 'Settings' },
|
|
136
|
+
]}
|
|
137
|
+
/>,
|
|
138
|
+
)
|
|
139
|
+
tab('Overview').focus()
|
|
140
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
141
|
+
expect(tab('Settings')).toHaveFocus()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('Home / End', () => {
|
|
146
|
+
it('Home jumps to the first tab', async () => {
|
|
147
|
+
render(<Harness initial="settings" />)
|
|
148
|
+
tab('Settings').focus()
|
|
149
|
+
await userEvent.keyboard('{Home}')
|
|
150
|
+
expect(tab('Overview')).toHaveFocus()
|
|
151
|
+
expect(tab('Overview')).toHaveAttribute('aria-selected', 'true')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('End jumps to the last tab', async () => {
|
|
155
|
+
render(<Harness />)
|
|
156
|
+
tab('Overview').focus()
|
|
157
|
+
await userEvent.keyboard('{End}')
|
|
158
|
+
expect(tab('Settings')).toHaveFocus()
|
|
159
|
+
expect(tab('Settings')).toHaveAttribute('aria-selected', 'true')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('panel wiring', () => {
|
|
164
|
+
it('gives every tab a stable id built from idPrefix', () => {
|
|
165
|
+
render(<Harness />)
|
|
166
|
+
expect(tab('Overview')).toHaveAttribute('id', 'demo-tab-overview')
|
|
167
|
+
expect(tab('Settings')).toHaveAttribute('id', 'demo-tab-settings')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('emits no aria-controls when the consumer renders no panel', () => {
|
|
171
|
+
render(<Harness />)
|
|
172
|
+
expect(tab('Overview')).not.toHaveAttribute('aria-controls')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('resolves aria-controls to a rendered element when panelId is supplied', () => {
|
|
176
|
+
render(
|
|
177
|
+
<div>
|
|
178
|
+
<Tabs
|
|
179
|
+
idPrefix="demo"
|
|
180
|
+
tabs={[
|
|
181
|
+
{ id: 'overview', label: 'Overview', panelId: 'demo-panel-overview' },
|
|
182
|
+
{ id: 'activity', label: 'Activity', panelId: 'demo-panel-activity' },
|
|
183
|
+
]}
|
|
184
|
+
activeTabId="overview"
|
|
185
|
+
onTabChange={() => {}}
|
|
186
|
+
/>
|
|
187
|
+
<div
|
|
188
|
+
id="demo-panel-overview"
|
|
189
|
+
role="tabpanel"
|
|
190
|
+
aria-labelledby="demo-tab-overview"
|
|
191
|
+
tabIndex={0}
|
|
192
|
+
>
|
|
193
|
+
Overview panel
|
|
194
|
+
</div>
|
|
195
|
+
</div>,
|
|
196
|
+
)
|
|
197
|
+
const controls = tab('Overview').getAttribute('aria-controls')
|
|
198
|
+
expect(controls).toBe('demo-panel-overview')
|
|
199
|
+
const panel = document.getElementById(controls ?? '')
|
|
200
|
+
expect(panel).not.toBeNull()
|
|
201
|
+
expect(panel).toHaveAttribute('role', 'tabpanel')
|
|
202
|
+
// ...and the panel names its tab back, which resolves too.
|
|
203
|
+
expect(document.getElementById('demo-tab-overview')).toBe(tab('Overview'))
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The `segmented` variant exists so the activity pane's strip can sit
|
|
209
|
+
* flush with the field-list header in the next pane. That is a HEIGHT
|
|
210
|
+
* claim, and the height is built the same way in both places — a 26px
|
|
211
|
+
* segment inside a 4px-padded, 1px-bordered track. jsdom lays nothing
|
|
212
|
+
* out, so what these pin is the construction the height comes from: if
|
|
213
|
+
* someone changes the track padding on one and not the other, the two
|
|
214
|
+
* headers stop lining up and this fails.
|
|
215
|
+
*/
|
|
216
|
+
describe('the segmented variant', () => {
|
|
217
|
+
const boxOf = (el: Element) => ({
|
|
218
|
+
padding: /p-\[var\(--space-1\)\]/.test(el.className),
|
|
219
|
+
border: /(^|\s)border(\s|$)/.test(el.className),
|
|
220
|
+
radius: (el as HTMLElement).style.borderRadius,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('builds the same box as SegmentedChoice, so the two headers align', () => {
|
|
224
|
+
const { unmount } = render(
|
|
225
|
+
<Tabs
|
|
226
|
+
variant="segmented"
|
|
227
|
+
stretch
|
|
228
|
+
tabs={TABS}
|
|
229
|
+
activeTabId="overview"
|
|
230
|
+
onTabChange={() => {}}
|
|
231
|
+
/>,
|
|
232
|
+
)
|
|
233
|
+
const track = screen.getByRole('tablist')
|
|
234
|
+
const segment = screen.getByRole('tab', { name: 'Overview' })
|
|
235
|
+
expect(boxOf(track)).toEqual({ padding: true, border: true, radius: 'var(--radius-md)' })
|
|
236
|
+
expect(segment.className).toContain('h-[26px]')
|
|
237
|
+
unmount()
|
|
238
|
+
|
|
239
|
+
render(
|
|
240
|
+
<SegmentedChoice
|
|
241
|
+
ariaLabel="Filter fields"
|
|
242
|
+
value="all"
|
|
243
|
+
onChange={() => {}}
|
|
244
|
+
items={[
|
|
245
|
+
{ id: 'all', label: 'All' },
|
|
246
|
+
{ id: 'filled', label: 'Filled' },
|
|
247
|
+
]}
|
|
248
|
+
/>,
|
|
249
|
+
)
|
|
250
|
+
const choice = screen.getByRole('group', { name: 'Filter fields' })
|
|
251
|
+
expect(boxOf(choice)).toEqual({ padding: true, border: true, radius: 'var(--radius-md)' })
|
|
252
|
+
expect(screen.getByRole('button', { name: 'All' }).className).toContain('h-[26px]')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('fills its container with equal-width segments', () => {
|
|
256
|
+
render(
|
|
257
|
+
<Tabs
|
|
258
|
+
variant="segmented"
|
|
259
|
+
stretch
|
|
260
|
+
tabs={TABS}
|
|
261
|
+
activeTabId="overview"
|
|
262
|
+
onTabChange={() => {}}
|
|
263
|
+
/>,
|
|
264
|
+
)
|
|
265
|
+
expect(screen.getByRole('tablist').className).toContain('w-full')
|
|
266
|
+
for (const label of ['Overview', 'Activity', 'Settings']) {
|
|
267
|
+
expect(screen.getByRole('tab', { name: label }).className).toContain('flex-1')
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('keeps a focus ring — the one variant with no underline to lose', () => {
|
|
272
|
+
// The active segment carries `--shadow-sm`. As an INLINE style that
|
|
273
|
+
// would beat `focus-visible:`, silently; as a class it does not.
|
|
274
|
+
render(
|
|
275
|
+
<Tabs
|
|
276
|
+
variant="segmented"
|
|
277
|
+
stretch
|
|
278
|
+
tabs={TABS}
|
|
279
|
+
activeTabId="overview"
|
|
280
|
+
onTabChange={() => {}}
|
|
281
|
+
/>,
|
|
282
|
+
)
|
|
283
|
+
const active = screen.getByRole('tab', { name: 'Overview' })
|
|
284
|
+
expect(active.getAttribute('style')).toBeNull()
|
|
285
|
+
expect(active.className).toContain('[box-shadow:var(--shadow-sm)]')
|
|
286
|
+
expect(active.className).toContain('focus-visible:[box-shadow:var(--ring-focus)]')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('is still a tablist — the keyboard model comes with it', async () => {
|
|
290
|
+
const user = userEvent.setup()
|
|
291
|
+
render(
|
|
292
|
+
<Harness variant="segmented" tabs={TABS} />,
|
|
293
|
+
)
|
|
294
|
+
await user.tab()
|
|
295
|
+
await user.keyboard('{ArrowRight}')
|
|
296
|
+
expect(screen.getByRole('tab', { name: 'Activity' })).toHaveAttribute(
|
|
297
|
+
'aria-selected',
|
|
298
|
+
'true',
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
@@ -137,15 +137,46 @@ describe('token shape', () => {
|
|
|
137
137
|
expect(violations).toEqual([])
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
-
it('never references a
|
|
140
|
+
it('never references a custom property nothing in the package defines', () => {
|
|
141
|
+
// A COMPONENT-LOCAL custom property is not a token, and a reference to one
|
|
142
|
+
// is not the typo this rule hunts. `.ds-avatar` declares `--avatar-hue` /
|
|
143
|
+
// `--avatar-size` and reads them back, so a hue or size modifier is one
|
|
144
|
+
// declaration instead of a repeated block; `--avatar-ring` exists precisely
|
|
145
|
+
// so the HOST can hand the primitive a colour the primitive cannot know
|
|
146
|
+
// (ADR-147 D14). Requiring those in tokens.css would mean either banning
|
|
147
|
+
// component-local properties outright or padding the shared vocabulary with
|
|
148
|
+
// entries nothing outside one component may use.
|
|
149
|
+
//
|
|
150
|
+
// So the rule is: a var() must resolve to a tokens.css token OR to a custom
|
|
151
|
+
// property declared somewhere in this package. Package-wide rather than
|
|
152
|
+
// per-file because the declaring rule and the reading element routinely sit
|
|
153
|
+
// in different files — a class in `styles.css` setting a property that a
|
|
154
|
+
// `.tsx` reads is the normal shape. A misspelt name is still declared
|
|
155
|
+
// nowhere and still fails, which is the whole point.
|
|
156
|
+
const declared = new Set(
|
|
157
|
+
FILES.flatMap((file) =>
|
|
158
|
+
readCode(file).flatMap((line) =>
|
|
159
|
+
[...line.matchAll(/(--[a-z0-9-]+)\s*:/g)].map(([, name]) => name!),
|
|
160
|
+
),
|
|
161
|
+
),
|
|
162
|
+
)
|
|
141
163
|
const violations: string[] = []
|
|
142
164
|
for (const file of FILES) {
|
|
143
165
|
readCode(file).forEach((line, i) => {
|
|
166
|
+
// BOTH forms are checked. An earlier pass excluded `var(--x, fallback)`
|
|
167
|
+
// on the theory that a fallback declares the property optional — but it
|
|
168
|
+
// also means a typo (`var(--surfce-card, white)`) renders the fallback
|
|
169
|
+
// forever with nothing to catch it. A genuine override knob declares
|
|
170
|
+
// itself and its default instead; see `--board-column-max-block-size`
|
|
171
|
+
// in styles.css.
|
|
144
172
|
for (const [, token] of line.matchAll(/var\((--[a-z0-9-]+)[,)]/g)) {
|
|
145
173
|
// Tailwind's own custom properties are not ours to define.
|
|
146
174
|
if (token!.startsWith('--tw-')) continue
|
|
175
|
+
if (declared.has(token!)) continue
|
|
147
176
|
if (!TOKENS.has(token!)) {
|
|
148
|
-
violations.push(
|
|
177
|
+
violations.push(
|
|
178
|
+
`${rel(file)}:${i + 1} — var(${token}) is declared nowhere in packages/ui`,
|
|
179
|
+
)
|
|
149
180
|
}
|
|
150
181
|
}
|
|
151
182
|
})
|
|
@@ -191,3 +222,102 @@ describe('undefined utility classes', () => {
|
|
|
191
222
|
expect(violations).toEqual([])
|
|
192
223
|
})
|
|
193
224
|
})
|
|
225
|
+
|
|
226
|
+
describe('a mark is never painted in a surface token', () => {
|
|
227
|
+
/**
|
|
228
|
+
* The defect this pins was MEASURED in a browser, not read out of the
|
|
229
|
+
* source: `GaugeRing` defaulted its track to `rgb(var(--muted))`, and
|
|
230
|
+
* `--muted` is declared as `var(--surface-inset)` — the recessed-well
|
|
231
|
+
* surface. A gauge dropped into a well therefore stroked its track in
|
|
232
|
+
* exactly the colour behind it: rgb(242,242,244) on rgb(242,242,244) in
|
|
233
|
+
* light and rgb(45,45,48) on rgb(45,45,48) in dark. **1.00:1 in both
|
|
234
|
+
* themes** — a 0% gauge rendered as bare text with no ring at all.
|
|
235
|
+
*
|
|
236
|
+
* Nothing about that is visible to typecheck, lint, build or a DOM-shape
|
|
237
|
+
* test: the attribute string `rgb(var(--muted))` is perfectly well-formed.
|
|
238
|
+
* What IS statically checkable is the relationship — that a token used as
|
|
239
|
+
* a MARK must not be an alias of a SURFACE. So this resolves the alias
|
|
240
|
+
* chain by name and asserts the mark never lands on one.
|
|
241
|
+
*
|
|
242
|
+
* Mutation check (ADR-146 D8's "a guard never observed failing is not a
|
|
243
|
+
* guard"): put `--muted` back as the default in `gauge-ring.tsx` and this
|
|
244
|
+
* test goes red, naming the alias chain it walked.
|
|
245
|
+
*/
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The surfaces a primitive may SIT ON. A mark may never be one of these.
|
|
249
|
+
*
|
|
250
|
+
* `--muted` is in the list because it aliases `--surface-inset`. That does
|
|
251
|
+
* NOT ban `--muted` as a small muted FILL — `RankedBars` fills its track
|
|
252
|
+
* with it and puts a `--border` stroke around it, which is the sanctioned
|
|
253
|
+
* shape (CLAUDE.md §2) and survives on any surface because the outline
|
|
254
|
+
* carries it. The scan below only reads named mark-COLOUR prop defaults,
|
|
255
|
+
* so that pattern is not matched.
|
|
256
|
+
*/
|
|
257
|
+
const SURFACE_TOKENS = new Set([
|
|
258
|
+
'--surface-card',
|
|
259
|
+
'--surface-frame',
|
|
260
|
+
'--surface-inset',
|
|
261
|
+
'--bg-card',
|
|
262
|
+
'--muted',
|
|
263
|
+
])
|
|
264
|
+
|
|
265
|
+
/** Follow `--a: var(--b)` until it stops being a pure alias. */
|
|
266
|
+
function resolveAlias(name: string): string[] {
|
|
267
|
+
const chain = [name]
|
|
268
|
+
let current = name
|
|
269
|
+
for (let hop = 0; hop < 8; hop += 1) {
|
|
270
|
+
const values = TOKENS.get(current)
|
|
271
|
+
if (values === undefined) break
|
|
272
|
+
// A pure alias is a value that is nothing but one var() reference.
|
|
273
|
+
const targets = values
|
|
274
|
+
.map((v) => /^var\((--[a-z0-9-]+)\)$/.exec(v.trim())?.[1])
|
|
275
|
+
.filter((v): v is string => v !== undefined)
|
|
276
|
+
const aliases = new Set(targets)
|
|
277
|
+
// Only follow when EVERY theme block declares the SAME alias. A token
|
|
278
|
+
// that aliases in one theme and is a literal in the other is a
|
|
279
|
+
// different problem, and stopping here reports it rather than
|
|
280
|
+
// silently walking one theme's chain.
|
|
281
|
+
if (aliases.size !== 1 || targets.length !== values.length) break
|
|
282
|
+
const next = [...aliases][0]!
|
|
283
|
+
if (chain.includes(next)) break
|
|
284
|
+
chain.push(next)
|
|
285
|
+
current = next
|
|
286
|
+
}
|
|
287
|
+
return chain
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Every `<name> = 'rgb(var(--token))'` default in a primitive whose name
|
|
292
|
+
* says it paints a mark — a track, a stroke, a fill, a rule.
|
|
293
|
+
*/
|
|
294
|
+
const MARK_DEFAULT =
|
|
295
|
+
/\b(trackColor|strokeColor|fillColor|ruleColor|barColor)\s*=\s*'rgb\(var\((--[a-z0-9-]+)\)\)'/g
|
|
296
|
+
|
|
297
|
+
it('resolves no mark default onto a surface token', () => {
|
|
298
|
+
const violations: string[] = []
|
|
299
|
+
for (const file of FILES) {
|
|
300
|
+
const source = readCode(file).join('\n')
|
|
301
|
+
for (const [, prop, token] of source.matchAll(MARK_DEFAULT)) {
|
|
302
|
+
const chain = resolveAlias(token!)
|
|
303
|
+
const hit = chain.find((step) => SURFACE_TOKENS.has(step))
|
|
304
|
+
if (hit !== undefined) {
|
|
305
|
+
violations.push(
|
|
306
|
+
`${rel(file)} — ${prop} defaults to ${token}, which resolves ` +
|
|
307
|
+
`${chain.join(' -> ')}; ${hit} is a SURFACE. A mark painted in ` +
|
|
308
|
+
`the surface it may sit on is invisible (measured 1.00:1).`,
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
expect(violations).toEqual([])
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('still recognises --muted as a surface alias (the guard, inverted)', () => {
|
|
317
|
+
// Proves the resolver actually walks the chain rather than passing
|
|
318
|
+
// because it found nothing. If `--muted` ever stops aliasing
|
|
319
|
+
// `--surface-inset`, THIS is what tells us the guard above went blind.
|
|
320
|
+
expect(resolveAlias('--muted')).toContain('--surface-inset')
|
|
321
|
+
expect(resolveAlias('--border-strong')).toEqual(['--border-strong'])
|
|
322
|
+
})
|
|
323
|
+
})
|