@astryxdesign/core 0.1.4-canary.39ffd21 → 0.1.4-canary.3d64ef2
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/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +8 -1
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +31 -11
- package/dist/Calendar/styles.d.ts +2 -2
- package/dist/Calendar/styles.d.ts.map +1 -1
- package/dist/Calendar/styles.js +2 -2
- package/dist/Card/Card.d.ts +4 -2
- package/dist/Card/Card.d.ts.map +1 -1
- package/dist/Card/Card.js +15 -7
- package/dist/Carousel/Carousel.d.ts.map +1 -1
- package/dist/Carousel/Carousel.js +1 -0
- package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
- package/dist/Chat/ChatToolCalls.js +8 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +3 -1
- package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
- package/dist/ClickableCard/ClickableCard.js +26 -3
- package/dist/CodeBlock/CodeBlock.d.ts +10 -1
- package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/CodeBlock/CodeBlock.js +23 -5
- package/dist/Collapsible/Collapsible.d.ts +9 -3
- package/dist/Collapsible/Collapsible.d.ts.map +1 -1
- package/dist/Collapsible/Collapsible.js +101 -13
- package/dist/Collapsible/CollapsibleGroup.d.ts +39 -7
- package/dist/Collapsible/CollapsibleGroup.d.ts.map +1 -1
- package/dist/Collapsible/CollapsibleGroup.js +70 -9
- package/dist/Collapsible/CollapsibleGroupContext.d.ts +31 -0
- package/dist/Collapsible/CollapsibleGroupContext.d.ts.map +1 -1
- package/dist/Collapsible/CollapsibleGroupContext.js +33 -3
- package/dist/Collapsible/index.d.ts +1 -0
- package/dist/Collapsible/index.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +2 -0
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +2 -0
- package/dist/Grid/Grid.d.ts +4 -3
- package/dist/Grid/Grid.d.ts.map +1 -1
- package/dist/Grid/Grid.js +26 -9
- package/dist/Icon/Icon.d.ts.map +1 -1
- package/dist/Icon/Icon.js +5 -1
- package/dist/InputGroup/InputGroup.js +2 -2
- package/dist/Item/Item.js +1 -1
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +26 -2
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +32 -16
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +87 -36
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +39 -21
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
- package/dist/ProgressBar/ProgressBar.js +2 -1
- package/dist/Resizable/ResizeHandle.d.ts.map +1 -1
- package/dist/Resizable/ResizeHandle.js +10 -2
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +6 -2
- package/dist/TabList/TabMenu.d.ts.map +1 -1
- package/dist/TabList/TabMenu.js +34 -7
- package/dist/Table/BaseTable.d.ts.map +1 -1
- package/dist/Table/BaseTable.js +14 -3
- package/dist/Table/plugins/columnResize/useTableColumnResize.d.ts.map +1 -1
- package/dist/Table/plugins/columnResize/useTableColumnResize.js +5 -2
- package/dist/Table/plugins/pagination/paginateData.d.ts.map +1 -1
- package/dist/Table/plugins/pagination/paginateData.js +5 -1
- package/dist/Table/plugins/selection/useTableSelectionState.d.ts.map +1 -1
- package/dist/Table/plugins/selection/useTableSelectionState.js +11 -1
- package/dist/Table/plugins/sortable/useTableSortableState.d.ts.map +1 -1
- package/dist/Table/plugins/sortable/useTableSortableState.js +8 -4
- package/dist/Thumbnail/Thumbnail.d.ts +3 -2
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +1 -0
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +6 -1
- package/dist/Toast/Toast.d.ts.map +1 -1
- package/dist/Toast/Toast.js +11 -3
- package/dist/Toast/ToastViewport.d.ts.map +1 -1
- package/dist/Toast/ToastViewport.js +12 -0
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +5 -2
- package/dist/astryx.css +7 -0
- package/dist/astryx.umd.js +48 -48
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/focusableSelector.d.ts +1 -1
- package/dist/hooks/focusableSelector.d.ts.map +1 -1
- package/dist/hooks/focusableSelector.js +1 -1
- package/dist/hooks/useFocusTrap.d.ts +3 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +38 -1
- package/dist/theme/defineTheme.d.ts +1 -1
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +10 -3
- package/dist/theme/hct.d.ts +0 -15
- package/dist/theme/hct.d.ts.map +1 -1
- package/dist/theme/hct.js +9 -9
- package/dist/theme/tokens.d.ts +10 -2
- package/dist/theme/tokens.d.ts.map +1 -1
- package/dist/theme/tokens.js +220 -4
- package/dist/utils/color.d.ts +42 -0
- package/dist/utils/color.d.ts.map +1 -0
- package/dist/utils/color.js +150 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/dist/utils/timeParser.d.ts.map +1 -1
- package/dist/utils/timeParser.js +8 -5
- package/package.json +1 -1
- package/src/Avatar/Avatar.doc.mjs +11 -0
- package/src/Banner/Banner.doc.mjs +8 -0
- package/src/Banner/Banner.test.tsx +38 -0
- package/src/Banner/Banner.tsx +8 -1
- package/src/Calendar/Calendar.doc.mjs +1 -1
- package/src/Calendar/Calendar.test.tsx +112 -4
- package/src/Calendar/Calendar.tsx +26 -5
- package/src/Calendar/styles.ts +12 -2
- package/src/Card/Card.tsx +16 -6
- package/src/Carousel/Carousel.doc.mjs +12 -12
- package/src/Carousel/Carousel.test.tsx +13 -0
- package/src/Carousel/Carousel.tsx +1 -0
- package/src/Chat/Chat.doc.mjs +1 -1
- package/src/Chat/ChatToolCalls.test.tsx +55 -0
- package/src/Chat/ChatToolCalls.tsx +10 -12
- package/src/CheckboxInput/CheckboxInput.doc.mjs +3 -3
- package/src/CheckboxInput/CheckboxInput.test.tsx +71 -4
- package/src/CheckboxInput/CheckboxInput.tsx +2 -0
- package/src/Citation/Citation.doc.mjs +5 -0
- package/src/ClickableCard/ClickableCard.tsx +45 -2
- package/src/CodeBlock/CodeBlock.doc.mjs +7 -2
- package/src/CodeBlock/CodeBlock.test.tsx +87 -3
- package/src/CodeBlock/CodeBlock.tsx +43 -3
- package/src/Collapsible/Collapsible.doc.mjs +8 -4
- package/src/Collapsible/Collapsible.tsx +86 -10
- package/src/Collapsible/CollapsibleGroup.doc.mjs +27 -3
- package/src/Collapsible/CollapsibleGroup.test.tsx +288 -0
- package/src/Collapsible/CollapsibleGroup.tsx +107 -9
- package/src/Collapsible/CollapsibleGroupContext.tsx +42 -2
- package/src/Collapsible/index.ts +4 -0
- package/src/CommandPalette/CommandPaletteEmpty.doc.mjs +5 -0
- package/src/CommandPalette/CommandPaletteGroup.doc.mjs +10 -0
- package/src/CommandPalette/CommandPaletteInput.doc.mjs +6 -0
- package/src/CommandPalette/CommandPaletteItem.doc.mjs +6 -0
- package/src/CommandPalette/CommandPaletteList.doc.mjs +9 -0
- package/src/ContextMenu/ContextMenu.doc.mjs +15 -0
- package/src/ContextMenu/ContextMenu.test.tsx +15 -0
- package/src/ContextMenu/ContextMenu.tsx +2 -0
- package/src/ContextMenu/ContextMenuItem.doc.mjs +6 -0
- package/src/DateTimeInput/DateTimeInput.test.tsx +70 -0
- package/src/DateTimeInput/DateTimeInput.tsx +2 -0
- package/src/Dialog/DialogHeader.doc.mjs +45 -0
- package/src/DropdownMenu/DropdownMenu.doc.mjs +1 -1
- package/src/Field/FieldLabel.test.tsx +2 -2
- package/src/FormLayout/__snapshots__/FormLayout.test.tsx.snap +0 -3
- package/src/Grid/Grid.test.tsx +43 -18
- package/src/Grid/Grid.tsx +26 -9
- package/src/Icon/Icon.test.tsx +41 -29
- package/src/Icon/Icon.tsx +6 -2
- package/src/InputGroup/InputGroup.tsx +2 -2
- package/src/Item/Item.tsx +1 -1
- package/src/Layout/LayoutContent.doc.mjs +12 -0
- package/src/Layout/LayoutPanel.doc.mjs +40 -0
- package/src/Lightbox/Lightbox.doc.mjs +5 -0
- package/src/Lightbox/Lightbox.test.tsx +123 -2
- package/src/Lightbox/Lightbox.tsx +29 -1
- package/src/NavMenu/NavMenu.doc.mjs +15 -0
- package/src/NumberInput/NumberInput.test.tsx +48 -3
- package/src/NumberInput/NumberInput.tsx +32 -15
- package/src/Overlay/Overlay.doc.mjs +6 -0
- package/src/Pagination/Pagination.test.tsx +143 -0
- package/src/Pagination/Pagination.tsx +75 -23
- package/src/Popover/Popover.test.tsx +65 -0
- package/src/PowerSearch/PowerSearch.doc.mjs +2 -2
- package/src/PowerSearch/PowerSearch.test.tsx +109 -1
- package/src/PowerSearch/PowerSearch.tsx +37 -16
- package/src/ProgressBar/ProgressBar.test.tsx +10 -0
- package/src/ProgressBar/ProgressBar.tsx +2 -1
- package/src/Resizable/ResizeHandle.test.tsx +180 -0
- package/src/Resizable/ResizeHandle.tsx +10 -2
- package/src/SegmentedControl/SegmentedControl.doc.mjs +1 -1
- package/src/SelectableCard/SelectableCard.doc.mjs +1 -1
- package/src/SideNav/SideNav.doc.mjs +7 -1
- package/src/SideNav/SideNav.test.tsx +20 -0
- package/src/SideNav/SideNav.tsx +6 -2
- package/src/TabList/TabList.test.tsx +185 -1
- package/src/TabList/TabMenu.tsx +45 -9
- package/src/Table/BaseTable.tsx +13 -2
- package/src/Table/Table.test.tsx +51 -0
- package/src/Table/plugins/columnResize/useTableColumnResize.test.tsx +13 -0
- package/src/Table/plugins/columnResize/useTableColumnResize.tsx +3 -1
- package/src/Table/plugins/pagination/paginateData.ts +5 -1
- package/src/Table/plugins/pagination/useTablePagination.test.tsx +13 -0
- package/src/Table/plugins/selection/useTableSelectionState.test.tsx +23 -0
- package/src/Table/plugins/selection/useTableSelectionState.tsx +11 -6
- package/src/Table/plugins/sortable/useTableSortableState.test.tsx +35 -1
- package/src/Table/plugins/sortable/useTableSortableState.tsx +8 -4
- package/src/TextArea/TextArea.test.tsx +3 -3
- package/src/TextInput/TextInput.test.tsx +3 -3
- package/src/Thumbnail/Thumbnail.test.tsx +36 -2
- package/src/Thumbnail/Thumbnail.tsx +4 -2
- package/src/TimeInput/TimeInput.test.tsx +37 -0
- package/src/TimeInput/TimeInput.tsx +11 -1
- package/src/Timestamp/Timestamp.doc.mjs +1 -1
- package/src/Toast/Toast.tsx +11 -3
- package/src/Toast/ToastViewport.test.tsx +64 -0
- package/src/Toast/ToastViewport.tsx +12 -0
- package/src/ToggleButton/ToggleButton.doc.mjs +1 -0
- package/src/Tokenizer/Tokenizer.doc.mjs +2 -2
- package/src/TopNav/TopNav.doc.mjs +1 -1
- package/src/TreeList/TreeList.doc.mjs +2 -2
- package/src/Typeahead/BaseTypeahead.doc.mjs +19 -0
- package/src/Typeahead/BaseTypeahead.tsx +5 -2
- package/src/Typeahead/Typeahead.doc.mjs +1 -1
- package/src/Typeahead/Typeahead.test.tsx +45 -1
- package/src/__tests__/TestIcon.tsx +26 -0
- package/src/docPropReferences.test.ts +178 -0
- package/src/hooks/focusableSelector.ts +1 -1
- package/src/hooks/useFocusTrap.doc.mjs +4 -2
- package/src/hooks/useFocusTrap.test.tsx +149 -1
- package/src/hooks/useFocusTrap.ts +50 -1
- package/src/theme/SyntaxTheme.doc.mjs +4 -4
- package/src/theme/defineTheme.ts +4 -13
- package/src/theme/derivedVarRegistry.test.ts +2 -2
- package/src/theme/expandColorScale.test.ts +29 -0
- package/src/theme/expandColorScale.ts +11 -6
- package/src/theme/hct.ts +9 -17
- package/src/theme/tokens.test.ts +182 -9
- package/src/theme/tokens.ts +239 -4
- package/src/theme/useTheme.test.tsx +20 -0
- package/src/utils/color.test.ts +133 -0
- package/src/utils/color.ts +148 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/timeParser.test.ts +13 -0
- package/src/utils/timeParser.ts +8 -5
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
4
|
+
/**
|
|
5
|
+
* @file Guards doc prose against docs/source prop mismatches (#3360).
|
|
6
|
+
*
|
|
7
|
+
* Scans every {Name}.doc.mjs for prose phrases like "the foo prop on Bar"
|
|
8
|
+
* and verifies the referenced prop is documented on the referenced
|
|
9
|
+
* component. Doc prose is LLM training signal: a reference to a prop that
|
|
10
|
+
* doesn't exist steers codegen toward hallucinated APIs (#3360 shipped a
|
|
11
|
+
* bullet pointing at a nonexistent CodeBlock syntaxTheme prop).
|
|
12
|
+
*
|
|
13
|
+
* A reference to a missing prop is allowed only when the same string
|
|
14
|
+
* explicitly negates it ("no such prop exists" / "does not exist"), the
|
|
15
|
+
* corrective-bullet pattern used to counter known hallucinations.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {describe, it, expect} from 'vitest';
|
|
19
|
+
import {readdirSync} from 'node:fs';
|
|
20
|
+
import {join, relative} from 'node:path';
|
|
21
|
+
|
|
22
|
+
const SRC_DIR = __dirname;
|
|
23
|
+
|
|
24
|
+
type DocModule = {
|
|
25
|
+
docs?: {
|
|
26
|
+
name?: string;
|
|
27
|
+
props?: {name: string}[];
|
|
28
|
+
components?: {name?: string; props?: {name: string}[]}[];
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Props intentionally omitted from doc props[]: PropDoc in docs-types.ts
|
|
34
|
+
* says to skip internal/styling props, so prose may reference them even
|
|
35
|
+
* though no doc lists them.
|
|
36
|
+
*/
|
|
37
|
+
const UNIVERSAL_PROPS = new Set([
|
|
38
|
+
'xstyle',
|
|
39
|
+
'className',
|
|
40
|
+
'style',
|
|
41
|
+
'ref',
|
|
42
|
+
'data-testid',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Matches prose like "the foo prop on Bar", "foo prop of Bar", and
|
|
47
|
+
* conjunction targets: "foo prop on Bar or Baz". Group 1 is the prop
|
|
48
|
+
* (camelCase, lowercase first letter), group 2 the component name list.
|
|
49
|
+
*/
|
|
50
|
+
const PROP_REF_PATTERN =
|
|
51
|
+
/\b([a-z][A-Za-z0-9]*) prop (?:on|of) (?:the )?([A-Z][A-Za-z0-9]*(?:(?:,(?: and| or)?| and| or) [A-Z][A-Za-z0-9]*)*)/g;
|
|
52
|
+
|
|
53
|
+
/** A same-string negation marks the reference as a deliberate counter-signal. */
|
|
54
|
+
const NEGATION_PATTERN = /no such prop|does not exist|doesn't exist/i;
|
|
55
|
+
|
|
56
|
+
function findDocFiles(dir: string): string[] {
|
|
57
|
+
const files: string[] = [];
|
|
58
|
+
for (const entry of readdirSync(dir, {withFileTypes: true})) {
|
|
59
|
+
const full = join(dir, entry.name);
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
files.push(...findDocFiles(full));
|
|
62
|
+
} else if (entry.name.endsWith('.doc.mjs')) {
|
|
63
|
+
files.push(full);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Collect every string value reachable from a doc module export. */
|
|
70
|
+
function collectStrings(value: unknown, out: string[]): void {
|
|
71
|
+
if (typeof value === 'string') {
|
|
72
|
+
out.push(value);
|
|
73
|
+
} else if (Array.isArray(value)) {
|
|
74
|
+
for (const item of value) {
|
|
75
|
+
collectStrings(item, out);
|
|
76
|
+
}
|
|
77
|
+
} else if (value && typeof value === 'object') {
|
|
78
|
+
for (const item of Object.values(value)) {
|
|
79
|
+
collectStrings(item, out);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const docFiles = findDocFiles(SRC_DIR);
|
|
85
|
+
|
|
86
|
+
// Component name → documented prop names, from every doc file's props[]
|
|
87
|
+
// (single and sub-component docs) and inline components[] entries.
|
|
88
|
+
const documentedProps = new Map<string, Set<string>>();
|
|
89
|
+
for (const file of docFiles) {
|
|
90
|
+
const mod = require(file) as DocModule;
|
|
91
|
+
const docs = mod.docs;
|
|
92
|
+
if (!docs) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const entries = [
|
|
96
|
+
{name: docs.name, props: docs.props},
|
|
97
|
+
...(docs.components || []),
|
|
98
|
+
];
|
|
99
|
+
for (const {name, props} of entries) {
|
|
100
|
+
if (!name || !Array.isArray(props)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const set = documentedProps.get(name) || new Set<string>();
|
|
104
|
+
for (const prop of props) {
|
|
105
|
+
set.add(prop.name);
|
|
106
|
+
}
|
|
107
|
+
documentedProps.set(name, set);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface Violation {
|
|
112
|
+
file: string;
|
|
113
|
+
component: string;
|
|
114
|
+
prop: string;
|
|
115
|
+
excerpt: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function findViolations(): Violation[] {
|
|
119
|
+
const violations: Violation[] = [];
|
|
120
|
+
for (const file of docFiles) {
|
|
121
|
+
const strings: string[] = [];
|
|
122
|
+
collectStrings(require(file), strings);
|
|
123
|
+
for (const text of strings) {
|
|
124
|
+
let match;
|
|
125
|
+
while ((match = PROP_REF_PATTERN.exec(text)) !== null) {
|
|
126
|
+
const [, prop, componentList] = match;
|
|
127
|
+
if (UNIVERSAL_PROPS.has(prop) || NEGATION_PATTERN.test(text)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
for (const component of componentList.split(
|
|
131
|
+
/,(?: and| or)?| and | or /,
|
|
132
|
+
)) {
|
|
133
|
+
const name = component.trim();
|
|
134
|
+
if (!name) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const known = documentedProps.get(name);
|
|
138
|
+
if (known && !known.has(prop)) {
|
|
139
|
+
violations.push({
|
|
140
|
+
file: relative(SRC_DIR, file),
|
|
141
|
+
component: name,
|
|
142
|
+
prop,
|
|
143
|
+
excerpt: text.slice(
|
|
144
|
+
Math.max(0, match.index - 40),
|
|
145
|
+
match.index + match[0].length + 20,
|
|
146
|
+
),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return violations;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe('doc prose prop references', () => {
|
|
157
|
+
it('sanity: discovers doc files and documented components', () => {
|
|
158
|
+
expect(docFiles.length).toBeGreaterThan(0);
|
|
159
|
+
expect(documentedProps.size).toBeGreaterThan(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('every "X prop on Y" reference names a documented prop of Y', () => {
|
|
163
|
+
const violations = findViolations();
|
|
164
|
+
expect(
|
|
165
|
+
violations,
|
|
166
|
+
violations
|
|
167
|
+
.map(
|
|
168
|
+
v =>
|
|
169
|
+
`${v.file} references prop "${v.prop}" on ${v.component}, ` +
|
|
170
|
+
`which does not document it (…${v.excerpt}…). Either the prop ` +
|
|
171
|
+
`doesn't exist (fix the prose; negate with "no such prop ` +
|
|
172
|
+
`exists" if countering a known hallucination) or it exists in ` +
|
|
173
|
+
`source but is missing from the component's doc props[] (add it).`,
|
|
174
|
+
)
|
|
175
|
+
.join('\n'),
|
|
176
|
+
).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -22,4 +22,4 @@
|
|
|
22
22
|
* re-declaring the string so behavior stays consistent across hooks.
|
|
23
23
|
*/
|
|
24
24
|
export const FOCUSABLE_SELECTOR =
|
|
25
|
-
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled]), [contenteditable]:not([contenteditable="false"]), audio[controls], video[controls], iframe, details > summary:first-child';
|
|
25
|
+
'button:not([disabled]), a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled]), [contenteditable]:not([contenteditable="false"]), audio[controls], video[controls], iframe, details > summary:first-child';
|
|
@@ -39,10 +39,11 @@ export const docs = {
|
|
|
39
39
|
],
|
|
40
40
|
usage: {
|
|
41
41
|
description:
|
|
42
|
-
'Traps focus within a container element following the WAI-ARIA dialog focus trap pattern. Listens to focus events on the document and redirects focus back into the container if it escapes via keyboard navigation. Handles both Tab and Shift+Tab wrapping. Mouse clicks outside the container are not intercepted; use a light-dismiss handler for that.',
|
|
42
|
+
'Traps focus within a container element following the WAI-ARIA dialog focus trap pattern. Listens to focus events on the document and redirects focus back into the container if it escapes via keyboard navigation. Handles both Tab and Shift+Tab wrapping. When the trap deactivates or unmounts, focus is restored to the element that was focused before activation, unless focus was already moved elsewhere. Mouse clicks outside the container are not intercepted; use a light-dismiss handler for that.',
|
|
43
43
|
bestPractices: [
|
|
44
44
|
{ guidance: true, description: 'Call focusFirst() when opening a dialog/modal to move focus into the trapped region.' },
|
|
45
45
|
{ guidance: true, description: 'Provide an onEscape callback to close the dialog when Escape is pressed.' },
|
|
46
|
+
{ guidance: true, description: 'Rely on the built-in focus restoration on close; only add your own onHide focus handling when you need to send focus somewhere other than the previously-focused element.' },
|
|
46
47
|
{ guidance: false, description: 'Use on non-modal content like tooltips or dropdowns; those need light-dismiss, not focus trapping.' },
|
|
47
48
|
],
|
|
48
49
|
},
|
|
@@ -67,10 +68,11 @@ export const docsDense = {
|
|
|
67
68
|
},
|
|
68
69
|
usage: {
|
|
69
70
|
description:
|
|
70
|
-
'Traps focus within container element following WAI-ARIA dialog focus trap pattern. Listens to document focus events + redirects focus back into container if it escapes via keyboard navigation. Handles both Tab + Shift+Tab wrapping. Mouse clicks outside container not intercepted; use light-dismiss handler for that.',
|
|
71
|
+
'Traps focus within container element following WAI-ARIA dialog focus trap pattern. Listens to document focus events + redirects focus back into container if it escapes via keyboard navigation. Handles both Tab + Shift+Tab wrapping. On deactivate/unmount, restores focus to the element focused before activation unless focus already moved elsewhere. Mouse clicks outside container not intercepted; use light-dismiss handler for that.',
|
|
71
72
|
bestPractices: [
|
|
72
73
|
{ guidance: true, description: 'Call focusFirst() when opening dialog/modal to move focus into trapped region.' },
|
|
73
74
|
{ guidance: true, description: 'Provide onEscape callback to close dialog when Escape pressed.' },
|
|
75
|
+
{ guidance: true, description: 'Rely on built-in focus restoration on close; add own onHide focus handling only to send focus somewhere other than previously-focused element.' },
|
|
74
76
|
{ guidance: false, description: 'Use on non-modal content like tooltips / dropdowns; those need light-dismiss, not focus trapping.' },
|
|
75
77
|
],
|
|
76
78
|
},
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import {describe, it, expect, vi} from 'vitest';
|
|
13
13
|
import {render, screen, fireEvent} from '@testing-library/react';
|
|
14
|
+
import {FOCUSABLE_SELECTOR} from './focusableSelector';
|
|
14
15
|
import {useFocusTrap} from './useFocusTrap';
|
|
15
16
|
|
|
16
17
|
function Trap({children}: {children: React.ReactNode}) {
|
|
@@ -49,6 +50,41 @@ function EscapeTrap({
|
|
|
49
50
|
);
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
function RestoreTrap({isActive}: {isActive: boolean}) {
|
|
54
|
+
const {containerRef} = useFocusTrap<HTMLDivElement>({isActive});
|
|
55
|
+
return (
|
|
56
|
+
<div ref={containerRef} data-testid="restore-trap">
|
|
57
|
+
<button type="button" data-testid="inside">
|
|
58
|
+
Inside trap
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function RestoreFixture({
|
|
65
|
+
isActive,
|
|
66
|
+
showPrev = true,
|
|
67
|
+
showTrap = true,
|
|
68
|
+
}: {
|
|
69
|
+
isActive: boolean;
|
|
70
|
+
showPrev?: boolean;
|
|
71
|
+
showTrap?: boolean;
|
|
72
|
+
}) {
|
|
73
|
+
return (
|
|
74
|
+
<div>
|
|
75
|
+
{showPrev && (
|
|
76
|
+
<button type="button" data-testid="prev">
|
|
77
|
+
Previously focused
|
|
78
|
+
</button>
|
|
79
|
+
)}
|
|
80
|
+
<button type="button" data-testid="other">
|
|
81
|
+
Other outside
|
|
82
|
+
</button>
|
|
83
|
+
{showTrap && <RestoreTrap isActive={isActive} />}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
52
88
|
describe('useFocusTrap tabbable model (infra-8)', () => {
|
|
53
89
|
it('treats a contenteditable as focusable (focusFirst lands on it)', () => {
|
|
54
90
|
render(
|
|
@@ -84,6 +120,48 @@ describe('useFocusTrap tabbable model (infra-8)', () => {
|
|
|
84
120
|
});
|
|
85
121
|
});
|
|
86
122
|
|
|
123
|
+
describe('FOCUSABLE_SELECTOR href matching', () => {
|
|
124
|
+
// Only real links (<a href>/<area href>) are focusable via href. A bare
|
|
125
|
+
// [href] term also matched non-focusable elements carrying href (e.g. a
|
|
126
|
+
// <link> in the head, or a custom element), which useFocusTrap would then
|
|
127
|
+
// treat as tab stops when computing trap boundaries.
|
|
128
|
+
it('matches real links but not other elements carrying href', () => {
|
|
129
|
+
const container = document.createElement('div');
|
|
130
|
+
container.innerHTML =
|
|
131
|
+
'<a href="#a" data-testid="anchor">Anchor</a>' +
|
|
132
|
+
'<map name="m">' +
|
|
133
|
+
'<area href="#area" shape="rect" coords="0,0,1,1" data-testid="area" />' +
|
|
134
|
+
'</map>' +
|
|
135
|
+
'<span href="#span" data-testid="span">Span</span>' +
|
|
136
|
+
'<link href="#link" data-testid="link" />';
|
|
137
|
+
|
|
138
|
+
const matches = Array.from(
|
|
139
|
+
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
|
140
|
+
);
|
|
141
|
+
const byTestId = (id: string) =>
|
|
142
|
+
container.querySelector(`[data-testid="${id}"]`);
|
|
143
|
+
|
|
144
|
+
// Real links are focusable via href.
|
|
145
|
+
expect(matches).toContain(byTestId('anchor'));
|
|
146
|
+
expect(matches).toContain(byTestId('area'));
|
|
147
|
+
// Non-link elements carrying href are not focusable and must be excluded.
|
|
148
|
+
expect(matches).not.toContain(byTestId('span'));
|
|
149
|
+
expect(matches).not.toContain(byTestId('link'));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('treats an <a href> inside a trap as focusable (focusFirst lands on it)', () => {
|
|
153
|
+
render(
|
|
154
|
+
<Trap>
|
|
155
|
+
<a href="#link" data-testid="anchor">
|
|
156
|
+
Link
|
|
157
|
+
</a>
|
|
158
|
+
</Trap>,
|
|
159
|
+
);
|
|
160
|
+
fireEvent.click(screen.getByTestId('focus-first'));
|
|
161
|
+
expect(screen.getByTestId('anchor')).toHaveFocus();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
87
165
|
describe('useFocusTrap Escape coordination', () => {
|
|
88
166
|
it('calls onEscape for a single active trap', () => {
|
|
89
167
|
const onEscape = vi.fn();
|
|
@@ -125,8 +203,78 @@ describe('useFocusTrap Escape coordination', () => {
|
|
|
125
203
|
const {rerender} = render(
|
|
126
204
|
<EscapeTrap isActive onEscape={onEscape} label="toggle" />,
|
|
127
205
|
);
|
|
128
|
-
rerender(
|
|
206
|
+
rerender(
|
|
207
|
+
<EscapeTrap isActive={false} onEscape={onEscape} label="toggle" />,
|
|
208
|
+
);
|
|
129
209
|
fireEvent.keyDown(document, {key: 'Escape'});
|
|
130
210
|
expect(onEscape).not.toHaveBeenCalled();
|
|
131
211
|
});
|
|
132
212
|
});
|
|
213
|
+
|
|
214
|
+
describe('useFocusTrap focus restoration', () => {
|
|
215
|
+
it('restores focus to the previously-focused element when deactivated', () => {
|
|
216
|
+
const {rerender} = render(<RestoreFixture isActive={false} />);
|
|
217
|
+
const prev = screen.getByTestId('prev');
|
|
218
|
+
prev.focus();
|
|
219
|
+
expect(prev).toHaveFocus();
|
|
220
|
+
|
|
221
|
+
// Activate the trap (captures `prev` as the restore target) and move focus
|
|
222
|
+
// inside it, as auto-focus or a keyboard user would.
|
|
223
|
+
rerender(<RestoreFixture isActive={true} />);
|
|
224
|
+
screen.getByTestId('inside').focus();
|
|
225
|
+
expect(screen.getByTestId('inside')).toHaveFocus();
|
|
226
|
+
|
|
227
|
+
// Deactivating returns focus to where it was before the trap opened.
|
|
228
|
+
rerender(<RestoreFixture isActive={false} />);
|
|
229
|
+
expect(prev).toHaveFocus();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('does not steal focus when it was moved elsewhere outside the trap', () => {
|
|
233
|
+
const {rerender} = render(<RestoreFixture isActive={false} />);
|
|
234
|
+
const prev = screen.getByTestId('prev');
|
|
235
|
+
prev.focus();
|
|
236
|
+
|
|
237
|
+
rerender(<RestoreFixture isActive={true} />);
|
|
238
|
+
// The user (or a consumer that self-restores) moves focus to a different
|
|
239
|
+
// outside control while the trap is open.
|
|
240
|
+
const other = screen.getByTestId('other');
|
|
241
|
+
other.focus();
|
|
242
|
+
|
|
243
|
+
rerender(<RestoreFixture isActive={false} />);
|
|
244
|
+
// Focus is left where the user put it — not yanked back to `prev`.
|
|
245
|
+
expect(other).toHaveFocus();
|
|
246
|
+
expect(prev).not.toHaveFocus();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('does not crash or restore when the captured element was removed', () => {
|
|
250
|
+
const {rerender} = render(<RestoreFixture isActive={false} />);
|
|
251
|
+
const prev = screen.getByTestId('prev');
|
|
252
|
+
prev.focus();
|
|
253
|
+
|
|
254
|
+
rerender(<RestoreFixture isActive={true} />);
|
|
255
|
+
screen.getByTestId('inside').focus();
|
|
256
|
+
|
|
257
|
+
// Remove the captured element from the DOM before the trap deactivates.
|
|
258
|
+
rerender(<RestoreFixture isActive={true} showPrev={false} />);
|
|
259
|
+
expect(() =>
|
|
260
|
+
rerender(<RestoreFixture isActive={false} showPrev={false} />),
|
|
261
|
+
).not.toThrow();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('restores focus when the trap unmounts while active', () => {
|
|
265
|
+
const {rerender} = render(
|
|
266
|
+
<RestoreFixture isActive={true} showTrap={false} />,
|
|
267
|
+
);
|
|
268
|
+
const prev = screen.getByTestId('prev');
|
|
269
|
+
prev.focus();
|
|
270
|
+
|
|
271
|
+
// Mounting the trap active captures `prev`; then focus moves inside it.
|
|
272
|
+
rerender(<RestoreFixture isActive={true} showTrap={true} />);
|
|
273
|
+
screen.getByTestId('inside').focus();
|
|
274
|
+
expect(screen.getByTestId('inside')).toHaveFocus();
|
|
275
|
+
|
|
276
|
+
// Unmounting the trap (cleanup path, not an isActive flip) still restores.
|
|
277
|
+
rerender(<RestoreFixture isActive={true} showTrap={false} />);
|
|
278
|
+
expect(prev).toHaveFocus();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @file useFocusTrap.ts
|
|
7
7
|
* @input Uses React useCallback, useEffect, useRef
|
|
8
|
-
* @output Exports useFocusTrap hook for trapping focus within a container
|
|
8
|
+
* @output Exports useFocusTrap hook for trapping focus within a container and
|
|
9
|
+
* restoring focus to the previously-focused element on deactivation
|
|
9
10
|
* @position Core hook; used by dialogs, modals, date pickers
|
|
10
11
|
*
|
|
11
12
|
* Based on WAI-ARIA dialog pattern:
|
|
@@ -178,6 +179,9 @@ export interface UseFocusTrapReturn<T extends HTMLElement = HTMLElement> {
|
|
|
178
179
|
* - Listens to focus events on the document
|
|
179
180
|
* - Redirects focus back into the container if it escapes
|
|
180
181
|
* - Handles both Tab and Shift+Tab navigation
|
|
182
|
+
* - Restores focus to the element that was focused before activation when the
|
|
183
|
+
* trap deactivates or unmounts, unless focus was already moved elsewhere
|
|
184
|
+
* (so consumers that restore focus themselves are unaffected)
|
|
181
185
|
*
|
|
182
186
|
* @example
|
|
183
187
|
* ```
|
|
@@ -217,6 +221,51 @@ export function useFocusTrap<T extends HTMLElement = HTMLElement>(
|
|
|
217
221
|
}
|
|
218
222
|
}, []);
|
|
219
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Capture the element focused before the trap activated, and restore focus to
|
|
226
|
+
* it when the trap deactivates (or the component unmounts). Overlays are
|
|
227
|
+
* opened imperatively (e.g. `showPopover()`), so the browser's declarative
|
|
228
|
+
* popover focus restoration does not apply — without this, closing a Popover
|
|
229
|
+
* via Escape or light dismiss drops keyboard focus to `<body>`.
|
|
230
|
+
*
|
|
231
|
+
* The restore is guarded so it never steals focus a consumer moved on
|
|
232
|
+
* purpose: it only runs when focus would otherwise be lost — i.e. the active
|
|
233
|
+
* element is nothing, the document body/root, or still inside the (possibly
|
|
234
|
+
* now-unmounted) trap container. If focus already moved to some other element
|
|
235
|
+
* outside the trap (the user clicked elsewhere, or a consumer such as
|
|
236
|
+
* DropdownMenu already refocused its trigger), the restore is a no-op.
|
|
237
|
+
*/
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (!isActive) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
244
|
+
// Snapshot the container now; by cleanup it may be detached or unmounted.
|
|
245
|
+
const container = containerRef.current;
|
|
246
|
+
|
|
247
|
+
return () => {
|
|
248
|
+
const active = document.activeElement;
|
|
249
|
+
const focusWasLost =
|
|
250
|
+
active == null ||
|
|
251
|
+
active === document.body ||
|
|
252
|
+
active === document.documentElement ||
|
|
253
|
+
(container != null && container.contains(active));
|
|
254
|
+
|
|
255
|
+
if (!focusWasLost) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (
|
|
260
|
+
previouslyFocused != null &&
|
|
261
|
+
previouslyFocused.isConnected &&
|
|
262
|
+
typeof previouslyFocused.focus === 'function'
|
|
263
|
+
) {
|
|
264
|
+
previouslyFocused.focus();
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}, [isActive]);
|
|
268
|
+
|
|
220
269
|
/**
|
|
221
270
|
* Handle focus events - redirect focus back into container if it escapes.
|
|
222
271
|
* Only redirects for keyboard navigation, not mouse clicks.
|
|
@@ -50,9 +50,9 @@ export const docs = {
|
|
|
50
50
|
'Syntax themes support light-dark() tuples: each token can have different values for light and dark mode, resolved automatically by the color scheme.',
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
|
-
guidance:
|
|
53
|
+
guidance: true,
|
|
54
54
|
description:
|
|
55
|
-
'
|
|
55
|
+
'For a single CodeBlock, pass the syntaxTheme prop directly: it is shorthand for wrapping that block in SyntaxTheme. Use the SyntaxTheme wrapper when theming a whole region of code components.',
|
|
56
56
|
},
|
|
57
57
|
],
|
|
58
58
|
},
|
|
@@ -96,9 +96,9 @@ export const docsDense = {
|
|
|
96
96
|
'Syntax themes support light-dark() tuples: each token can have different values for light/dark mode, resolved automatically by color scheme.',
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
|
-
guidance:
|
|
99
|
+
guidance: true,
|
|
100
100
|
description:
|
|
101
|
-
'
|
|
101
|
+
'Single CodeBlock: pass syntaxTheme prop (shorthand for wrapping in SyntaxTheme). Whole region of code components: wrap w/ SyntaxTheme.',
|
|
102
102
|
},
|
|
103
103
|
],
|
|
104
104
|
},
|
package/src/theme/defineTheme.ts
CHANGED
|
@@ -56,14 +56,8 @@ import {
|
|
|
56
56
|
generateTypeScaleComponents,
|
|
57
57
|
type TypeScaleConfig,
|
|
58
58
|
} from './expandTypeScale';
|
|
59
|
-
import {
|
|
60
|
-
|
|
61
|
-
type MotionScaleConfig,
|
|
62
|
-
} from './expandMotionScale';
|
|
63
|
-
import {
|
|
64
|
-
expandRadiusScale,
|
|
65
|
-
type RadiusScaleConfig,
|
|
66
|
-
} from './expandRadiusScale';
|
|
59
|
+
import {expandMotionScale, type MotionScaleConfig} from './expandMotionScale';
|
|
60
|
+
import {expandRadiusScale, type RadiusScaleConfig} from './expandRadiusScale';
|
|
67
61
|
import {expandColorScale, type ColorScaleConfig} from './expandColorScale';
|
|
68
62
|
|
|
69
63
|
import type {DomainTokenName} from './domainTokens';
|
|
@@ -153,10 +147,7 @@ export type StyleOverrides = Record<string, string | Record<string, string>>;
|
|
|
153
147
|
* }
|
|
154
148
|
* ```
|
|
155
149
|
*/
|
|
156
|
-
export type ComponentStyleMap = Record<
|
|
157
|
-
string,
|
|
158
|
-
Record<string, StyleOverrides>
|
|
159
|
-
>;
|
|
150
|
+
export type ComponentStyleMap = Record<string, Record<string, StyleOverrides>>;
|
|
160
151
|
|
|
161
152
|
/** Input to defineTheme */
|
|
162
153
|
export interface DefineThemeInput {
|
|
@@ -287,7 +278,7 @@ export interface DefineThemeInput {
|
|
|
287
278
|
/**
|
|
288
279
|
* Default syntax highlighting theme for code components.
|
|
289
280
|
* Sets --color-syntax-* tokens at the theme root. Can be overridden
|
|
290
|
-
* per-region
|
|
281
|
+
* per-region (or per-instance) by wrapping in SyntaxTheme.
|
|
291
282
|
*
|
|
292
283
|
* @example
|
|
293
284
|
* ```tsx
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import {describe, it, expect} from 'vitest';
|
|
16
16
|
import {derivedVarRegistry, getDerivedVars} from './derivedVarRegistry';
|
|
17
|
-
import {readdirSync, readFileSync} from 'fs';
|
|
18
|
-
import {join} from 'path';
|
|
17
|
+
import {readdirSync, readFileSync} from 'node:fs';
|
|
18
|
+
import {join} from 'node:path';
|
|
19
19
|
|
|
20
20
|
const SRC_DIR = join(__dirname, '..');
|
|
21
21
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {describe, it, expect} from 'vitest';
|
|
4
4
|
import {expandColorScale} from './expandColorScale';
|
|
5
5
|
import {defineTheme} from './defineTheme';
|
|
6
|
+
import {generateThemeRules} from './generateThemeRules';
|
|
6
7
|
|
|
7
8
|
describe('expandColorScale', () => {
|
|
8
9
|
it('produces all expected token keys', () => {
|
|
@@ -60,6 +61,19 @@ describe('expandColorScale', () => {
|
|
|
60
61
|
expect(warm['--color-neutral']).not.toBe(neutral['--color-neutral']);
|
|
61
62
|
});
|
|
62
63
|
|
|
64
|
+
it('emits derived accent tokens as references to --color-accent', () => {
|
|
65
|
+
const tokens = expandColorScale({accent: '#0064E0'});
|
|
66
|
+
// Reference tokens follow a scoped --color-accent override at runtime.
|
|
67
|
+
expect(tokens['--color-text-accent']).toBe('var(--color-accent)');
|
|
68
|
+
expect(tokens['--color-icon-accent']).toBe('var(--color-accent)');
|
|
69
|
+
expect(tokens['--color-accent-muted']).toBe(
|
|
70
|
+
'light-dark(color-mix(in srgb, var(--color-accent) 20%, transparent), color-mix(in srgb, var(--color-accent) 25%, transparent))',
|
|
71
|
+
);
|
|
72
|
+
// The base token and the contrast-computed on-accent stay resolved.
|
|
73
|
+
expect(tokens['--color-accent']).toMatch(/^light-dark\(#/);
|
|
74
|
+
expect(tokens['--color-on-accent']).toMatch(/^light-dark\(#/);
|
|
75
|
+
});
|
|
76
|
+
|
|
63
77
|
it('contrast high produces different --color-text-primary than standard', () => {
|
|
64
78
|
const standard = expandColorScale({
|
|
65
79
|
accent: '#0064E0',
|
|
@@ -81,4 +95,19 @@ describe('expandColorScale + defineTheme integration', () => {
|
|
|
81
95
|
});
|
|
82
96
|
expect(theme.tokens['--color-accent']).toBe('red');
|
|
83
97
|
});
|
|
98
|
+
|
|
99
|
+
it('generated theme CSS keeps the accent references (#3495)', () => {
|
|
100
|
+
const theme = defineTheme({
|
|
101
|
+
name: 'test-accent-refs',
|
|
102
|
+
color: {accent: '#DC2626'},
|
|
103
|
+
});
|
|
104
|
+
const css = generateThemeRules(theme).join('\n');
|
|
105
|
+
expect(css).toContain('--color-text-accent: var(--color-accent);');
|
|
106
|
+
expect(css).toContain('--color-icon-accent: var(--color-accent);');
|
|
107
|
+
expect(css).toContain(
|
|
108
|
+
'--color-accent-muted: light-dark(color-mix(in srgb, var(--color-accent) 20%, transparent), color-mix(in srgb, var(--color-accent) 25%, transparent));',
|
|
109
|
+
);
|
|
110
|
+
// The base token itself stays a resolved color pair.
|
|
111
|
+
expect(css).toMatch(/--color-accent: light-dark\(#/);
|
|
112
|
+
});
|
|
84
113
|
});
|
|
@@ -77,6 +77,10 @@ function ld(light: string, dark: string): string {
|
|
|
77
77
|
return `light-dark(${light}, ${dark})`;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function accentWithAlpha(alpha: number): string {
|
|
81
|
+
return `color-mix(in srgb, var(--color-accent) ${alpha * 100}%, transparent)`;
|
|
82
|
+
}
|
|
83
|
+
|
|
80
84
|
/**
|
|
81
85
|
* Expand a color scale config into Astryx color token overrides.
|
|
82
86
|
*
|
|
@@ -117,10 +121,11 @@ export function expandColorScale(
|
|
|
117
121
|
return {
|
|
118
122
|
// Core semantic
|
|
119
123
|
'--color-accent': ld(P[40], P[80]),
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
// Derived accent tokens reference --color-accent instead of baking its
|
|
125
|
+
// resolved hex, so a scoped override of the base token re-accents the
|
|
126
|
+
// whole subtree at runtime. --color-on-accent stays baked: it is a
|
|
127
|
+
// contrast computation against the accent, which CSS cannot express.
|
|
128
|
+
'--color-accent-muted': ld(accentWithAlpha(0.2), accentWithAlpha(0.25)),
|
|
124
129
|
'--color-on-accent': ld(P[100], P[20]),
|
|
125
130
|
'--color-neutral': ld(hexWithAlpha(N[10], 0.1), hexWithAlpha(N[90], 0.2)),
|
|
126
131
|
'--color-background-surface': ld(N[99], N[10]),
|
|
@@ -146,10 +151,10 @@ export function expandColorScale(
|
|
|
146
151
|
NV[textSecondaryDarkTone],
|
|
147
152
|
),
|
|
148
153
|
'--color-text-disabled': ld(NV[60], NV[40]),
|
|
149
|
-
'--color-text-accent':
|
|
154
|
+
'--color-text-accent': 'var(--color-accent)',
|
|
150
155
|
|
|
151
156
|
// Icon
|
|
152
|
-
'--color-icon-accent':
|
|
157
|
+
'--color-icon-accent': 'var(--color-accent)',
|
|
153
158
|
'--color-icon-primary': ld(N[textPrimaryLightTone], N[textPrimaryDarkTone]),
|
|
154
159
|
'--color-icon-secondary': ld(
|
|
155
160
|
NV[textSecondaryLightTone],
|
package/src/theme/hct.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* - Tone from L* (CIE Lightness, 0=black, 100=white)
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import {parseHex, formatHex} from '../utils/color';
|
|
20
|
+
|
|
19
21
|
// =============================================================================
|
|
20
22
|
// Types
|
|
21
23
|
// =============================================================================
|
|
@@ -121,21 +123,11 @@ export function yToTone(y: number): number {
|
|
|
121
123
|
// =============================================================================
|
|
122
124
|
|
|
123
125
|
function hexToRgb(hex: string): [number, number, number] {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return [
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function rgbToHex(r: number, g: number, b: number): string {
|
|
132
|
-
return (
|
|
133
|
-
'#' +
|
|
134
|
-
[r, g, b]
|
|
135
|
-
.map(c => c.toString(16).padStart(2, '0'))
|
|
136
|
-
.join('')
|
|
137
|
-
.toUpperCase()
|
|
138
|
-
);
|
|
126
|
+
const parsed = parseHex(hex);
|
|
127
|
+
if (parsed === null) {
|
|
128
|
+
return [0, 0, 0];
|
|
129
|
+
}
|
|
130
|
+
return [parsed.r, parsed.g, parsed.b];
|
|
139
131
|
}
|
|
140
132
|
|
|
141
133
|
// =============================================================================
|
|
@@ -181,7 +173,7 @@ export function hctToHex(hct: HCT): string {
|
|
|
181
173
|
}
|
|
182
174
|
if (chroma < 0.5) {
|
|
183
175
|
const gray = toneToGray(tone);
|
|
184
|
-
return
|
|
176
|
+
return formatHex(gray, gray, gray);
|
|
185
177
|
}
|
|
186
178
|
|
|
187
179
|
let lo = 0;
|
|
@@ -241,7 +233,7 @@ function hctComponentToHex(
|
|
|
241
233
|
return null;
|
|
242
234
|
}
|
|
243
235
|
|
|
244
|
-
return
|
|
236
|
+
return formatHex(r, g, bVal);
|
|
245
237
|
}
|
|
246
238
|
|
|
247
239
|
// =============================================================================
|