@astryxdesign/core 0.7.0 → 0.7.1-canary.0365e23
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/BottomSheet/BottomSheet.d.ts +1 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +12 -2
- package/dist/CodeBlock/CodeBlock.d.ts +10 -1
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +158 -38
- package/dist/CodeBlock/tokenizer.d.ts +39 -0
- package/dist/CodeBlock/tokenizer.d.ts.map +1 -1
- package/dist/CodeBlock/tokenizer.js +108 -0
- package/dist/ContextMenu/ContextMenu.d.ts +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +22 -8
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -26
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +10 -3
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +19 -3
- package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +12 -3
- package/dist/astryx.css +13 -0
- package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
- package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
- package/dist/theme/syntax/SyntaxTheme.d.ts.map +1 -1
- package/dist/theme/syntax/SyntaxTheme.js +43 -2
- package/dist/theme/syntax/defineSyntaxTheme.d.ts +18 -5
- package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.js +38 -9
- package/dist/theme/syntax/index.d.ts +1 -1
- package/dist/theme/syntax/index.d.ts.map +1 -1
- package/dist/theme/syntax/tokens.d.ts +12 -2
- package/dist/theme/syntax/tokens.d.ts.map +1 -1
- package/dist/theme/syntax/tokens.js +25 -4
- package/dist/utils/focusReturn.d.ts +12 -8
- package/dist/utils/focusReturn.d.ts.map +1 -1
- package/dist/utils/focusReturn.js +44 -3
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.test.tsx +114 -0
- package/src/BottomSheet/BottomSheet.tsx +12 -2
- package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
- package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
- package/src/CodeBlock/CodeBlock.doc.mjs +7 -0
- package/src/CodeBlock/CodeBlock.spec.md +55 -45
- package/src/CodeBlock/CodeBlock.test.tsx +89 -9
- package/src/CodeBlock/CodeBlock.tsx +205 -25
- package/src/CodeBlock/__tests__/CodeBlockOverflowStop.a11y.chromium.spec.ts +125 -0
- package/src/CodeBlock/__tests__/DiffRowPaint.a11y.chromium.spec.ts +93 -0
- package/src/CodeBlock/tokenizer.test.ts +112 -0
- package/src/CodeBlock/tokenizer.ts +106 -0
- package/src/ContextMenu/ContextMenu.spec.md +1 -1
- package/src/ContextMenu/ContextMenu.test.tsx +36 -3
- package/src/ContextMenu/ContextMenu.tsx +27 -11
- package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
- package/src/Dialog/Dialog.tsx +2 -30
- package/src/Dialog/__tests__/Dialog.a11y.chromium.spec.ts +21 -14
- package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
- package/src/DropdownMenu/DropdownMenu.tsx +10 -3
- package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
- package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +21 -14
- package/src/Markdown/Markdown.spec.md +37 -23
- package/src/Markdown/Markdown.test.tsx +38 -0
- package/src/Markdown/Markdown.tsx +20 -2
- package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
- package/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts +22 -15
- package/src/PowerSearch/PowerSearch.test.tsx +45 -0
- package/src/Switch/__tests__/Switch.a11y.chromium.spec.ts +25 -18
- package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +19 -12
- package/src/TextInput/__tests__/TextInput.a11y.chromium.spec.ts +21 -14
- package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +18 -11
- package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
- package/src/Typeahead/BaseTypeahead.tsx +17 -3
- package/src/theme/syntax/SyntaxTheme.tsx +46 -5
- package/src/theme/syntax/defineSyntaxTheme.diff.test.ts +91 -0
- package/src/theme/syntax/defineSyntaxTheme.ts +75 -15
- package/src/theme/syntax/index.ts +5 -1
- package/src/theme/syntax/tokens.ts +32 -2
- package/src/theme/syntax/useSyntaxTheme.diff.test.tsx +40 -0
- package/src/utils/focusReturn.test.ts +79 -2
- package/src/utils/focusReturn.ts +48 -3
|
@@ -166,29 +166,17 @@ test('the state inventory describes the labels the page actually renders', async
|
|
|
166
166
|
expect(wrong).toEqual([]);
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// test takes.
|
|
175
|
-
test.setTimeout(5 * 60 * 1000);
|
|
176
|
-
const cdp = await page.context().newCDPSession(page);
|
|
177
|
-
const results: BindingResult[] = [];
|
|
178
|
-
for (const state of SWITCH_BINDING_STATES) {
|
|
179
|
-
results.push(await runState(page, cdp, state));
|
|
180
|
-
}
|
|
181
|
-
// An expectation that applies to nothing is not coverage, however green it
|
|
182
|
-
// looks. This is the only place that can notice: the contract never sees the
|
|
183
|
-
// states, so whether a condition matches a real one is a binding-side fact.
|
|
184
|
-
expect(neverExercised(results)).toEqual([]);
|
|
185
|
-
expect(unmatchedKnownFailures(SWITCH_KNOWN_FAILURES, results)).toEqual([]);
|
|
186
|
-
});
|
|
169
|
+
// Each state's result, recorded by its own test below so the whole-suite check
|
|
170
|
+
// at the end reads it instead of measuring every state a second time. A state
|
|
171
|
+
// whose test did not run in this worker (a filtered run, or a worker restarted
|
|
172
|
+
// after a failure) is measured by the whole-suite check itself.
|
|
173
|
+
const recorded = new Map<string, BindingResult>();
|
|
187
174
|
|
|
188
175
|
for (const state of SWITCH_BINDING_STATES) {
|
|
189
176
|
test(`Switch [${state.id}] — ${state.summary}`, async ({page}) => {
|
|
190
177
|
const cdp = await page.context().newCDPSession(page);
|
|
191
178
|
const result = await runState(page, cdp, state);
|
|
179
|
+
recorded.set(state.id, result);
|
|
192
180
|
const report = summarize(SWITCH_PATTERN, [result]);
|
|
193
181
|
// eslint-disable-next-line no-console -- the report is this run's artifact
|
|
194
182
|
console.log(formatReport(report));
|
|
@@ -201,3 +189,22 @@ for (const state of SWITCH_BINDING_STATES) {
|
|
|
201
189
|
expect(report.counts.unexpectedPass).toBe(0);
|
|
202
190
|
});
|
|
203
191
|
}
|
|
192
|
+
|
|
193
|
+
test('every expectation is exercised by at least one bound state', async ({
|
|
194
|
+
page,
|
|
195
|
+
}) => {
|
|
196
|
+
// A whole-binding sweep: every state's result, read from its own test or,
|
|
197
|
+
// when that test did not run in this worker, measured here (minutes of real
|
|
198
|
+
// browser work in that case, so the budget stays).
|
|
199
|
+
test.setTimeout(5 * 60 * 1000);
|
|
200
|
+
const cdp = await page.context().newCDPSession(page);
|
|
201
|
+
const results: BindingResult[] = [];
|
|
202
|
+
for (const state of SWITCH_BINDING_STATES) {
|
|
203
|
+
results.push(recorded.get(state.id) ?? (await runState(page, cdp, state)));
|
|
204
|
+
}
|
|
205
|
+
// An expectation that applies to nothing is not coverage, however green it
|
|
206
|
+
// looks. This is the only place that can notice: the contract never sees the
|
|
207
|
+
// states, so whether a condition matches a real one is a binding-side fact.
|
|
208
|
+
expect(neverExercised(results)).toEqual([]);
|
|
209
|
+
expect(unmatchedKnownFailures(SWITCH_KNOWN_FAILURES, results)).toEqual([]);
|
|
210
|
+
});
|
|
@@ -163,24 +163,18 @@ test('every binding fact matches the rendered semantic state', async ({
|
|
|
163
163
|
expect(wrong).toEqual([]);
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const results: BindingResult[] = [];
|
|
172
|
-
for (const state of TABS_BINDING_STATES) {
|
|
173
|
-
results.push(await runState(page, cdp, state));
|
|
174
|
-
}
|
|
175
|
-
expect(neverExercised(results)).toEqual([]);
|
|
176
|
-
expect(unmatchedKnownFailures(TABS_KNOWN_FAILURES, results)).toEqual([]);
|
|
177
|
-
});
|
|
166
|
+
// Each state's result, recorded by its own test below so the whole-suite check
|
|
167
|
+
// at the end reads it instead of measuring every state a second time. A state
|
|
168
|
+
// whose test did not run in this worker (a filtered run, or a worker restarted
|
|
169
|
+
// after a failure) is measured by the whole-suite check itself.
|
|
170
|
+
const recorded = new Map<string, BindingResult>();
|
|
178
171
|
|
|
179
172
|
for (const state of TABS_BINDING_STATES) {
|
|
180
173
|
test(`${state.binding} [${state.id}] — ${state.summary}`, async ({page}) => {
|
|
181
174
|
test.setTimeout(3 * 60 * 1000);
|
|
182
175
|
const cdp = await page.context().newCDPSession(page);
|
|
183
176
|
const result = await runState(page, cdp, state);
|
|
177
|
+
recorded.set(state.id, result);
|
|
184
178
|
const report = summarize(TABS_PATTERN, [result]);
|
|
185
179
|
// eslint-disable-next-line no-console -- the report is this run's artifact
|
|
186
180
|
console.log(formatReport(report));
|
|
@@ -189,3 +183,16 @@ for (const state of TABS_BINDING_STATES) {
|
|
|
189
183
|
expect(report.counts.unexpectedPass).toBe(0);
|
|
190
184
|
});
|
|
191
185
|
}
|
|
186
|
+
|
|
187
|
+
test('every expectation is exercised by at least one bound state', async ({
|
|
188
|
+
page,
|
|
189
|
+
}) => {
|
|
190
|
+
test.setTimeout(5 * 60 * 1000);
|
|
191
|
+
const cdp = await page.context().newCDPSession(page);
|
|
192
|
+
const results: BindingResult[] = [];
|
|
193
|
+
for (const state of TABS_BINDING_STATES) {
|
|
194
|
+
results.push(recorded.get(state.id) ?? (await runState(page, cdp, state)));
|
|
195
|
+
}
|
|
196
|
+
expect(neverExercised(results)).toEqual([]);
|
|
197
|
+
expect(unmatchedKnownFailures(TABS_KNOWN_FAILURES, results)).toEqual([]);
|
|
198
|
+
});
|
|
@@ -176,26 +176,18 @@ test('every state declaration matches the browser accessibility node', async ({
|
|
|
176
176
|
expect(wrong).toEqual([]);
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const results: BindingResult[] = [];
|
|
185
|
-
for (const state of TEXT_INPUT_BINDING_STATES) {
|
|
186
|
-
results.push(await runState(page, cdp, state));
|
|
187
|
-
}
|
|
188
|
-
expect(neverExercised(results)).toEqual([]);
|
|
189
|
-
expect(unmatchedKnownFailures(TEXT_INPUT_KNOWN_FAILURES, results)).toEqual(
|
|
190
|
-
[],
|
|
191
|
-
);
|
|
192
|
-
});
|
|
179
|
+
// Each state's result, recorded by its own test below so the whole-suite check
|
|
180
|
+
// at the end reads it instead of measuring every state a second time. A state
|
|
181
|
+
// whose test did not run in this worker (a filtered run, or a worker restarted
|
|
182
|
+
// after a failure) is measured by the whole-suite check itself.
|
|
183
|
+
const recorded = new Map<string, BindingResult>();
|
|
193
184
|
|
|
194
185
|
for (const state of TEXT_INPUT_BINDING_STATES) {
|
|
195
186
|
test(`${state.binding} [${state.id}] — ${state.summary}`, async ({page}) => {
|
|
196
187
|
test.setTimeout(3 * 60 * 1000);
|
|
197
188
|
const cdp = await page.context().newCDPSession(page);
|
|
198
189
|
const result = await runState(page, cdp, state);
|
|
190
|
+
recorded.set(state.id, result);
|
|
199
191
|
const report = summarize(TEXT_INPUT_PATTERN, [result]);
|
|
200
192
|
// eslint-disable-next-line no-console -- the report is this run's artifact
|
|
201
193
|
console.log(formatReport(report));
|
|
@@ -204,3 +196,18 @@ for (const state of TEXT_INPUT_BINDING_STATES) {
|
|
|
204
196
|
expect(report.counts.unexpectedPass).toBe(0);
|
|
205
197
|
});
|
|
206
198
|
}
|
|
199
|
+
|
|
200
|
+
test('every expectation is exercised and every known failure matches once', async ({
|
|
201
|
+
page,
|
|
202
|
+
}) => {
|
|
203
|
+
test.setTimeout(8 * 60 * 1000);
|
|
204
|
+
const cdp = await page.context().newCDPSession(page);
|
|
205
|
+
const results: BindingResult[] = [];
|
|
206
|
+
for (const state of TEXT_INPUT_BINDING_STATES) {
|
|
207
|
+
results.push(recorded.get(state.id) ?? (await runState(page, cdp, state)));
|
|
208
|
+
}
|
|
209
|
+
expect(neverExercised(results)).toEqual([]);
|
|
210
|
+
expect(unmatchedKnownFailures(TEXT_INPUT_KNOWN_FAILURES, results)).toEqual(
|
|
211
|
+
[],
|
|
212
|
+
);
|
|
213
|
+
});
|
|
@@ -143,22 +143,17 @@ test('the state inventory matches rendered labels and applicability facts', asyn
|
|
|
143
143
|
expect(wrong).toEqual([]);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const results: BindingResult[] = [];
|
|
152
|
-
for (const state of TOGGLE_BUTTON_BINDING_STATES) {
|
|
153
|
-
results.push(await runState(page, cdp, state));
|
|
154
|
-
}
|
|
155
|
-
expect(neverExercised(results)).toEqual([]);
|
|
156
|
-
});
|
|
146
|
+
// Each state's result, recorded by its own test below so the whole-suite check
|
|
147
|
+
// at the end reads it instead of measuring every state a second time. A state
|
|
148
|
+
// whose test did not run in this worker (a filtered run, or a worker restarted
|
|
149
|
+
// after a failure) is measured by the whole-suite check itself.
|
|
150
|
+
const recorded = new Map<string, BindingResult>();
|
|
157
151
|
|
|
158
152
|
for (const state of TOGGLE_BUTTON_BINDING_STATES) {
|
|
159
153
|
test(`ToggleButton [${state.id}] — ${state.summary}`, async ({page}) => {
|
|
160
154
|
const cdp = await page.context().newCDPSession(page);
|
|
161
155
|
const result = await runState(page, cdp, state);
|
|
156
|
+
recorded.set(state.id, result);
|
|
162
157
|
const report = summarize(TOGGLE_BUTTON_PATTERN, [result]);
|
|
163
158
|
// eslint-disable-next-line no-console -- the report is this run's evidence
|
|
164
159
|
console.log(formatReport(report));
|
|
@@ -167,3 +162,15 @@ for (const state of TOGGLE_BUTTON_BINDING_STATES) {
|
|
|
167
162
|
expect(report.counts.unexpectedPass).toBe(0);
|
|
168
163
|
});
|
|
169
164
|
}
|
|
165
|
+
|
|
166
|
+
test('every expectation is exercised by at least one ToggleButton state', async ({
|
|
167
|
+
page,
|
|
168
|
+
}) => {
|
|
169
|
+
test.setTimeout(5 * 60 * 1000);
|
|
170
|
+
const cdp = await page.context().newCDPSession(page);
|
|
171
|
+
const results: BindingResult[] = [];
|
|
172
|
+
for (const state of TOGGLE_BUTTON_BINDING_STATES) {
|
|
173
|
+
results.push(recorded.get(state.id) ?? (await runState(page, cdp, state)));
|
|
174
|
+
}
|
|
175
|
+
expect(neverExercised(results)).toEqual([]);
|
|
176
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file BaseTypeahead.test.tsx
|
|
5
5
|
* @input BaseTypeahead public props and a synchronous SearchSource
|
|
6
|
-
* @output Combobox contract tests, including stale source refresh on reopen
|
|
6
|
+
* @output Combobox contract tests, including stale source refresh on focus and reopen
|
|
7
7
|
* @position Colocated verification for BaseTypeahead
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -209,6 +209,104 @@ describe('BaseTypeahead', () => {
|
|
|
209
209
|
expect(input).toHaveFocus();
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
it.each([
|
|
213
|
+
['a click', 'click'],
|
|
214
|
+
['keyboard focus', 'tab'],
|
|
215
|
+
] as const)(
|
|
216
|
+
'bootstraps the replacement source when %s refocuses the input',
|
|
217
|
+
async (_label, refocus) => {
|
|
218
|
+
const user = userEvent.setup();
|
|
219
|
+
const originalSource: SearchSource<SearchableItem> = {
|
|
220
|
+
search: () => [],
|
|
221
|
+
bootstrap: () => [resultItem],
|
|
222
|
+
};
|
|
223
|
+
const updatedBootstrap = vi.fn(() => [
|
|
224
|
+
{id: '2', label: 'Updated result'},
|
|
225
|
+
]);
|
|
226
|
+
const updatedSource: SearchSource<SearchableItem> = {
|
|
227
|
+
search: () => [],
|
|
228
|
+
bootstrap: updatedBootstrap,
|
|
229
|
+
};
|
|
230
|
+
const renderField = (searchSource: SearchSource<SearchableItem>) => (
|
|
231
|
+
<>
|
|
232
|
+
<BaseTypeahead
|
|
233
|
+
searchSource={searchSource}
|
|
234
|
+
value={null}
|
|
235
|
+
onChange={() => {}}
|
|
236
|
+
hasEntriesOnFocus
|
|
237
|
+
/>
|
|
238
|
+
<button type="button">Sort</button>
|
|
239
|
+
</>
|
|
240
|
+
);
|
|
241
|
+
const {rerender} = render(renderField(originalSource));
|
|
242
|
+
const input = screen.getByRole('combobox');
|
|
243
|
+
const sortButton = screen.getByRole('button', {name: 'Sort'});
|
|
244
|
+
|
|
245
|
+
await user.click(input);
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Focus moves to another control, which then hands the field a new
|
|
251
|
+
// source (a sort or filter change rebuilding the caller's source).
|
|
252
|
+
await user.click(sortButton);
|
|
253
|
+
expect(input).not.toHaveFocus();
|
|
254
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
255
|
+
rerender(renderField(updatedSource));
|
|
256
|
+
|
|
257
|
+
if (refocus === 'click') {
|
|
258
|
+
await user.click(input);
|
|
259
|
+
} else {
|
|
260
|
+
await user.tab({shift: true});
|
|
261
|
+
}
|
|
262
|
+
expect(input).toHaveFocus();
|
|
263
|
+
await waitFor(() => {
|
|
264
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
265
|
+
expect(
|
|
266
|
+
screen.getByRole('option', {name: 'Updated result', hidden: true}),
|
|
267
|
+
).toBeInTheDocument();
|
|
268
|
+
});
|
|
269
|
+
expect(
|
|
270
|
+
screen.queryByRole('option', {name: 'Result', hidden: true}),
|
|
271
|
+
).not.toBeInTheDocument();
|
|
272
|
+
expect(updatedBootstrap).toHaveBeenCalledTimes(1);
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
it('re-shows cached results without bootstrapping again when the source is unchanged', async () => {
|
|
277
|
+
const user = userEvent.setup();
|
|
278
|
+
const bootstrap = vi.fn(() => [resultItem]);
|
|
279
|
+
const source: SearchSource<SearchableItem> = {search: () => [], bootstrap};
|
|
280
|
+
render(
|
|
281
|
+
<>
|
|
282
|
+
<BaseTypeahead
|
|
283
|
+
searchSource={source}
|
|
284
|
+
value={null}
|
|
285
|
+
onChange={() => {}}
|
|
286
|
+
hasEntriesOnFocus
|
|
287
|
+
/>
|
|
288
|
+
<button type="button">Sort</button>
|
|
289
|
+
</>,
|
|
290
|
+
);
|
|
291
|
+
const input = screen.getByRole('combobox');
|
|
292
|
+
|
|
293
|
+
await user.click(input);
|
|
294
|
+
await waitFor(() => {
|
|
295
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
296
|
+
});
|
|
297
|
+
await user.click(screen.getByRole('button', {name: 'Sort'}));
|
|
298
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
299
|
+
|
|
300
|
+
await user.click(input);
|
|
301
|
+
await waitFor(() => {
|
|
302
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
303
|
+
});
|
|
304
|
+
expect(
|
|
305
|
+
screen.getByRole('option', {name: 'Result', hidden: true}),
|
|
306
|
+
).toBeInTheDocument();
|
|
307
|
+
expect(bootstrap).toHaveBeenCalledTimes(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
212
310
|
it('counts grapheme clusters when enforcing minQueryLength', async () => {
|
|
213
311
|
const search = vi.fn(() => [resultItem]);
|
|
214
312
|
render(
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @file BaseTypeahead.tsx
|
|
7
7
|
* @input Uses React, StyleX, usePopover, TypeaheadItem
|
|
8
|
-
* @output Exports BaseTypeahead
|
|
8
|
+
* @output Exports BaseTypeahead; focus bootstraps a replaced search source
|
|
9
9
|
* @position Core implementation; used by Typeahead and Tokenizer
|
|
10
10
|
*
|
|
11
11
|
* Pure combobox engine: input, search, keyboard navigation, dropdown.
|
|
@@ -610,6 +610,10 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
610
610
|
// in the closure are stale (a selection cleared them) and shouldn't
|
|
611
611
|
// be re-shown.
|
|
612
612
|
const resultsGenRef = useRef(0);
|
|
613
|
+
// The generation at which the current searchSource took over. Results
|
|
614
|
+
// populated before it (resultsGenRef below this) came from a replaced
|
|
615
|
+
// source: they can never be re-shown, so focus bootstraps the new source.
|
|
616
|
+
const sourceGenRef = useRef(0);
|
|
613
617
|
|
|
614
618
|
// Results still arriving from a replaced source must not land in the new
|
|
615
619
|
// one's menu.
|
|
@@ -617,7 +621,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
617
621
|
if (prevSearchSourceRef.current !== searchSource) {
|
|
618
622
|
prevSearchSourceRef.current.cancel?.();
|
|
619
623
|
prevSearchSourceRef.current = searchSource;
|
|
620
|
-
searchGenRef.current
|
|
624
|
+
sourceGenRef.current = ++searchGenRef.current;
|
|
621
625
|
}
|
|
622
626
|
|
|
623
627
|
// Layer for dropdown
|
|
@@ -889,11 +893,21 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
889
893
|
// Shared by handleFocus and handleClick: bootstrap entries if none are
|
|
890
894
|
// loaded yet, or re-show cached results that haven't been invalidated by
|
|
891
895
|
// a selection since (comparing the two generation refs catches that).
|
|
896
|
+
// Results cached from a replaced search source count as none, so focusing
|
|
897
|
+
// an empty field bootstraps the new source instead of opening nothing.
|
|
898
|
+
// Results a selection invalidated still keep the menu closed when the
|
|
899
|
+
// selection returns focus to the input.
|
|
892
900
|
const openIfEligible = useCallback(() => {
|
|
893
901
|
if (isDisabled) {
|
|
894
902
|
return;
|
|
895
903
|
}
|
|
896
|
-
|
|
904
|
+
const hasResultsFromCurrentSource =
|
|
905
|
+
results.length > 0 && resultsGenRef.current >= sourceGenRef.current;
|
|
906
|
+
if (
|
|
907
|
+
hasEntriesOnFocus &&
|
|
908
|
+
!hasResultsFromCurrentSource &&
|
|
909
|
+
query.length === 0
|
|
910
|
+
) {
|
|
897
911
|
void performBootstrap();
|
|
898
912
|
} else if (
|
|
899
913
|
results.length > 0 &&
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
type SyntaxThemeTokenKey,
|
|
21
21
|
} from './defineSyntaxTheme';
|
|
22
22
|
import {useMediaQuery} from '../../hooks/useMediaQuery';
|
|
23
|
+
import {useTheme} from '../useTheme';
|
|
24
|
+
import {formatColor, parseColor} from '../../utils/color';
|
|
23
25
|
|
|
24
26
|
// =============================================================================
|
|
25
27
|
// Context
|
|
@@ -59,6 +61,22 @@ export interface UseSyntaxThemeReturn {
|
|
|
59
61
|
// Hook
|
|
60
62
|
// =============================================================================
|
|
61
63
|
|
|
64
|
+
/** The strength of the default diff row paints: the muted tokens at 25%. */
|
|
65
|
+
const DIFF_TINT_STRENGTH = 0.25;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The colour `color-mix(in srgb, <colour> <strength>, transparent)` paints, as
|
|
69
|
+
* a value a canvas can use. Returns undefined for a colour the shared parser
|
|
70
|
+
* does not read (the CSS default then stands).
|
|
71
|
+
*/
|
|
72
|
+
function mixWithTransparent(
|
|
73
|
+
colour: string,
|
|
74
|
+
strength: number,
|
|
75
|
+
): string | undefined {
|
|
76
|
+
const parsed = parseColor(colour);
|
|
77
|
+
return parsed ? formatColor({...parsed, a: parsed.a * strength}) : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
/**
|
|
63
81
|
* Access the current syntax theme's token values, resolved for the active
|
|
64
82
|
* color mode. Returns null if no SyntaxTheme provider is present.
|
|
@@ -74,6 +92,7 @@ export interface UseSyntaxThemeReturn {
|
|
|
74
92
|
*/
|
|
75
93
|
export function useSyntaxTheme(): UseSyntaxThemeReturn | null {
|
|
76
94
|
const ctx = use(SyntaxThemeContext);
|
|
95
|
+
const page = useTheme();
|
|
77
96
|
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
|
|
78
97
|
const effectiveMode: 'light' | 'dark' = prefersDark ? 'dark' : 'light';
|
|
79
98
|
|
|
@@ -83,13 +102,35 @@ export function useSyntaxTheme(): UseSyntaxThemeReturn | null {
|
|
|
83
102
|
}
|
|
84
103
|
const resolved: Partial<Record<SyntaxThemeTokenKey, string>> = {};
|
|
85
104
|
for (const key of ALL_SYNTAX_KEYS) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
effectiveMode
|
|
89
|
-
|
|
105
|
+
const input = ctx.theme.__inputTokens[key];
|
|
106
|
+
if (input !== undefined) {
|
|
107
|
+
resolved[key] = resolveSyntaxTokenForMode(input, effectiveMode);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// A diff row slot the theme did not set follows its CSS default; a canvas
|
|
111
|
+
// consumer needs a colour it can paint, so the marker slots take the
|
|
112
|
+
// theme's own resolved punctuation colour and the row paints derive from
|
|
113
|
+
// the enclosing page theme's resolved tokens (the same mix the CSS
|
|
114
|
+
// default makes), never from a probe outside the nearest Theme.
|
|
115
|
+
for (const key of ALL_SYNTAX_KEYS) {
|
|
116
|
+
if (resolved[key] !== undefined) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const fallback = ctx.theme.tokens[key] ?? '';
|
|
120
|
+
if (key === 'added-marker' || key === 'removed-marker') {
|
|
121
|
+
resolved[key] = resolved.punctuation ?? fallback;
|
|
122
|
+
} else if (key === 'added' || key === 'removed') {
|
|
123
|
+
const base = page.token(
|
|
124
|
+
key === 'added' ? '--color-success-muted' : '--color-error-muted',
|
|
125
|
+
);
|
|
126
|
+
resolved[key] =
|
|
127
|
+
mixWithTransparent(base, DIFF_TINT_STRENGTH) ?? fallback;
|
|
128
|
+
} else {
|
|
129
|
+
resolved[key] = fallback;
|
|
130
|
+
}
|
|
90
131
|
}
|
|
91
132
|
return resolved as Record<SyntaxThemeTokenKey, string>;
|
|
92
|
-
}, [ctx, effectiveMode]);
|
|
133
|
+
}, [ctx, effectiveMode, page]);
|
|
93
134
|
|
|
94
135
|
if (!ctx || !tokens) {
|
|
95
136
|
return null;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it, vi} from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
defineSyntaxTheme,
|
|
6
|
+
REQUIRED_SYNTAX_KEYS,
|
|
7
|
+
syntaxThemeStyle,
|
|
8
|
+
syntaxThemeToCSS,
|
|
9
|
+
} from './defineSyntaxTheme';
|
|
10
|
+
import {syntaxTokenDefaults} from './tokens';
|
|
11
|
+
import {dracula} from './presets';
|
|
12
|
+
|
|
13
|
+
const base = Object.fromEntries(
|
|
14
|
+
REQUIRED_SYNTAX_KEYS.map(key => [key, '#123456']),
|
|
15
|
+
) as Parameters<typeof defineSyntaxTheme>[0]['tokens'];
|
|
16
|
+
|
|
17
|
+
describe('diff row slots in a syntax theme', () => {
|
|
18
|
+
it('are optional: a theme that sets none keeps the semantic defaults', () => {
|
|
19
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
20
|
+
const theme = defineSyntaxTheme({name: 't', tokens: base});
|
|
21
|
+
expect(warn).not.toHaveBeenCalled();
|
|
22
|
+
expect(theme.tokens.added).toBe(
|
|
23
|
+
syntaxTokenDefaults['--color-syntax-added'],
|
|
24
|
+
);
|
|
25
|
+
expect(theme.tokens.removed).toBe(
|
|
26
|
+
syntaxTokenDefaults['--color-syntax-removed'],
|
|
27
|
+
);
|
|
28
|
+
// A defined theme that sets no header paint gets none: the header row
|
|
29
|
+
// sits on the theme's own background with punctuation text.
|
|
30
|
+
expect(theme.tokens['diff-header']).toBe('transparent');
|
|
31
|
+
// A theme that sets no marker colour keeps its own line-number colour.
|
|
32
|
+
expect(theme.tokens['added-marker']).toBe(
|
|
33
|
+
'var(--color-syntax-punctuation)',
|
|
34
|
+
);
|
|
35
|
+
expect(theme.tokens['removed-marker']).toBe(
|
|
36
|
+
'var(--color-syntax-punctuation)',
|
|
37
|
+
);
|
|
38
|
+
warn.mockRestore();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('may be overridden to harmonize with the theme', () => {
|
|
42
|
+
const theme = defineSyntaxTheme({
|
|
43
|
+
name: 't',
|
|
44
|
+
tokens: {...base, added: ['#e6ffed', '#1b3a24'], removed: '#ffeef0'},
|
|
45
|
+
});
|
|
46
|
+
expect(theme.tokens.added).toBe('light-dark(#e6ffed, #1b3a24)');
|
|
47
|
+
expect(theme.tokens.removed).toBe('#ffeef0');
|
|
48
|
+
expect(theme.tokens['diff-header']).toBe('transparent');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('skips unset diff slots in the CSS and style output of a hand-built definition', () => {
|
|
52
|
+
const tokens = {...dracula.tokens};
|
|
53
|
+
delete (tokens as Partial<typeof tokens>).added;
|
|
54
|
+
delete (tokens as Partial<typeof tokens>)['added-marker'];
|
|
55
|
+
const theme = {...dracula, tokens};
|
|
56
|
+
expect(syntaxThemeToCSS(theme)).not.toContain('undefined');
|
|
57
|
+
expect(syntaxThemeToCSS(theme)).not.toContain('--color-syntax-added:');
|
|
58
|
+
expect(syntaxThemeStyle(theme)).not.toHaveProperty('--color-syntax-added');
|
|
59
|
+
expect(syntaxThemeStyle(theme)).toHaveProperty('--color-syntax-removed');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('leaves the shipped presets valid without them: row paints default, markers follow punctuation', () => {
|
|
63
|
+
expect(dracula.tokens.added).toBe(
|
|
64
|
+
syntaxTokenDefaults['--color-syntax-added'],
|
|
65
|
+
);
|
|
66
|
+
expect(dracula.tokens['added-marker']).toBe(
|
|
67
|
+
'var(--color-syntax-punctuation)',
|
|
68
|
+
);
|
|
69
|
+
expect(dracula.tokens['removed-marker']).toBe(
|
|
70
|
+
'var(--color-syntax-punctuation)',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('defaults derive from the semantic tokens, never a literal colour', () => {
|
|
75
|
+
expect(syntaxTokenDefaults['--color-syntax-added']).toBe(
|
|
76
|
+
'color-mix(in srgb, var(--color-success-muted) 25%, transparent)',
|
|
77
|
+
);
|
|
78
|
+
expect(syntaxTokenDefaults['--color-syntax-removed']).toBe(
|
|
79
|
+
'color-mix(in srgb, var(--color-error-muted) 25%, transparent)',
|
|
80
|
+
);
|
|
81
|
+
expect(syntaxTokenDefaults['--color-syntax-diff-header']).toBe(
|
|
82
|
+
'var(--color-background-muted)',
|
|
83
|
+
);
|
|
84
|
+
expect(syntaxTokenDefaults['--color-syntax-added-marker']).toBe(
|
|
85
|
+
'var(--color-text-green)',
|
|
86
|
+
);
|
|
87
|
+
expect(syntaxTokenDefaults['--color-syntax-removed-marker']).toBe(
|
|
88
|
+
'var(--color-text-red)',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|