@patternfly/design-tokens 1.15.1 → 1.16.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.
Files changed (83) hide show
  1. package/build/css/tokens-dark.scss +3 -3
  2. package/build/css/tokens-default.scss +4 -5
  3. package/build/css/{tokens-redhat-dark.scss → tokens-felt-dark.scss} +1 -1
  4. package/build/css/{tokens-redhat-glass-dark.scss → tokens-felt-glass-dark.scss} +4 -3
  5. package/build/css/{tokens-redhat-glass.scss → tokens-felt-glass.scss} +4 -2
  6. package/build/css/{tokens-redhat-highcontrast-dark.scss → tokens-felt-highcontrast-dark.scss} +5 -3
  7. package/build/css/tokens-felt-highcontrast.scss +121 -0
  8. package/build/css/{tokens-redhat.scss → tokens-felt.scss} +1 -1
  9. package/build/css/tokens-glass-dark.scss +2 -2
  10. package/build/css/tokens-glass.scss +1 -1
  11. package/build/css/tokens-palette.scss +1 -1
  12. package/build/css/tokens-redhat-highcontrast.scss +641 -10
  13. package/build.js +42 -42
  14. package/{config.redhat-dark.json → config.felt-dark.json} +2 -2
  15. package/{config.redhat-glass-dark.json → config.felt-glass-dark.json} +2 -2
  16. package/{config.redhat-glass.json → config.felt-glass.json} +2 -2
  17. package/{config.redhat-highcontrast-dark.json → config.felt-highcontrast-dark.json} +2 -2
  18. package/{config.redhat.json → config.felt.json} +2 -2
  19. package/{config.layers.redhat-dark.json → config.layers.felt-dark.json} +2 -2
  20. package/{config.layers.redhat-glass-dark.json → config.layers.felt-glass-dark.json} +2 -2
  21. package/{config.layers.redhat-glass.json → config.layers.felt-glass.json} +2 -2
  22. package/{config.layers.redhat-highcontrast-dark.json → config.layers.felt-highcontrast-dark.json} +2 -2
  23. package/package.json +1 -1
  24. package/patternfly-docs/content/token-layers-felt-dark.json +48543 -0
  25. package/patternfly-docs/content/token-layers-felt-glass-dark.json +37670 -0
  26. package/patternfly-docs/content/token-layers-felt-glass.json +52576 -0
  27. package/patternfly-docs/content/token-layers-felt-highcontrast-dark.json +38323 -0
  28. package/patternfly-docs/content/token-layers-glass-dark.json +4214 -4247
  29. package/patternfly-docs/content/token-layers-glass.json +4997 -5049
  30. package/patternfly-docs/content/token-layers-highcontrast-dark.json +2978 -3005
  31. package/patternfly-docs/content/token-layers-highcontrast.json +4177 -4229
  32. package/patternfly-docs/content/token-layers-redhat-dark.json +147 -170
  33. package/patternfly-docs/content/token-layers-redhat-glass-dark.json +165 -194
  34. package/patternfly-docs/content/token-layers-redhat-glass.json +298 -346
  35. package/patternfly-docs/content/token-layers-redhat-highcontrast-dark.json +46 -69
  36. package/patternfly-docs/content/token-layers-redhat-highcontrast.json +4911 -4641
  37. package/patternfly-docs/content/token-layers-redhat.json +6147 -5881
  38. package/patternfly-docs/content/tokensTable.css +178 -0
  39. package/patternfly-docs/content/tokensTable.js +1078 -180
  40. package/patternfly-docs/content/tokensToolbar.js +240 -11
  41. package/patternfly-docs/generated/foundations-and-styles/design-tokens/all-design-tokens/design-tokens.js +37 -3
  42. package/patternfly-docs/generated/index.js +1 -0
  43. package/plugins/export-patternfly-tokens/dist/ui.html +344 -334
  44. package/plugins/export-patternfly-tokens/src/ui.tsx +44 -34
  45. package/tokens/default/dark/base.dark.json +42 -42
  46. package/tokens/default/dark/charts.dark.json +32 -32
  47. package/tokens/default/dark/charts.highcontrast.dark.json +765 -0
  48. package/tokens/default/dark/palette.color.json +4 -4
  49. package/tokens/default/dark/semantic.dark.json +115 -115
  50. package/tokens/default/glass/base.dimension.json +24 -24
  51. package/tokens/default/glass/palette.color.json +4 -4
  52. package/tokens/default/glass/semantic.dimension.glass.json +137 -141
  53. package/tokens/default/glass/semantic.glass.json +114 -114
  54. package/tokens/default/glass-dark/base.dark.json +42 -42
  55. package/tokens/default/glass-dark/palette.color.json +4 -4
  56. package/tokens/default/glass-dark/semantic.glass.dark.json +115 -115
  57. package/tokens/default/highcontrast/base.dimension.json +24 -24
  58. package/tokens/default/highcontrast/palette.color.json +4 -4
  59. package/tokens/default/highcontrast/semantic.dimension.highcontrast.json +136 -140
  60. package/tokens/default/highcontrast/semantic.highcontrast.json +114 -114
  61. package/tokens/default/highcontrast-dark/base.dark.json +42 -42
  62. package/tokens/default/highcontrast-dark/palette.color.json +4 -4
  63. package/tokens/default/highcontrast-dark/semantic.highcontrast.dark.json +115 -115
  64. package/tokens/default/light/base.dimension.json +24 -24
  65. package/tokens/default/light/charts.highcontrast.json +765 -0
  66. package/tokens/default/light/charts.json +32 -32
  67. package/tokens/default/light/palette.color.json +4 -4
  68. package/tokens/default/light/semantic.dimension.json +137 -141
  69. package/tokens/default/light/semantic.json +114 -114
  70. package/tokens/default/light/semantic.motion.json +17 -17
  71. package/tokens/{redhat/dark/redhat.color.dark.json → felt/dark/felt.color.dark.json} +19 -19
  72. package/tokens/{redhat/light/redhat.color.json → felt/glass/felt.color.glass.json} +10 -10
  73. package/tokens/{redhat/highcontrast/redhat.dimension.highcontrast.json → felt/glass/felt.dimension.glass.json} +6 -6
  74. package/tokens/{redhat/glass-dark/redhat.color.glass.dark.json → felt/glass-dark/felt.color.glass.dark.json} +22 -17
  75. package/tokens/{redhat/highcontrast/redhat.color.highcontrast.json → felt/highcontrast/felt.color.highcontrast.json} +10 -10
  76. package/tokens/{redhat/light/redhat.dimension.json → felt/highcontrast/felt.dimension.highcontrast.json} +6 -6
  77. package/tokens/{redhat/highcontrast-dark/redhat.color.highcontrast.dark.json → felt/highcontrast-dark/felt.color.highcontrast.dark.json} +10 -10
  78. package/tokens/{redhat/glass/redhat.color.glass.json → felt/light/felt.color.json} +10 -10
  79. package/tokens/felt/light/felt.dimension.json +23 -0
  80. package/tokens/redhat/glass/redhat.dimension.glass.json +0 -2
  81. /package/{config.redhat-highcontrast.json → config.felt-highcontrast.json} +0 -0
  82. /package/{config.layers.redhat-highcontrast.json → config.layers.felt-highcontrast.json} +0 -0
  83. /package/{config.layers.redhat.json → config.layers.felt.json} +0 -0
@@ -7,9 +7,10 @@ import {
7
7
  EmptyStateActions,
8
8
  Flex,
9
9
  FlexItem,
10
- Grid,
11
- GridItem,
10
+ Pagination,
11
+ Popover,
12
12
  Title,
13
+ Tooltip,
13
14
  capitalize
14
15
  } from '@patternfly/react-core';
15
16
  import {
@@ -19,80 +20,74 @@ import {
19
20
  Tr,
20
21
  Tbody,
21
22
  Td,
22
- ExpandableRowContent,
23
23
  OuterScrollContainer,
24
24
  InnerScrollContainer
25
25
  } from '@patternfly/react-table';
26
- import { TokensToolbar } from './tokensToolbar';
26
+ import {
27
+ TokensToolbar,
28
+ ThemeDisplayLabel,
29
+ ThemeLabelAbbrevContext,
30
+ getThemeDisplayName
31
+ } from './tokensToolbar';
27
32
  import './tokensTable.css';
28
33
  import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon';
29
-
30
34
  import c_expandable_section_m_display_lg_PaddingInlineStart from '@patternfly/react-tokens/dist/esm/c_expandable_section_m_display_lg_PaddingInlineStart';
31
35
  import LevelUpAltIcon from '@patternfly/react-icons/dist/esm/icons/level-up-alt-icon';
32
36
 
33
37
  {
34
38
  /* Helper functions */
35
39
  }
36
- // Used to combine data grouped by theme under each token name
37
- const deepMerge = (target, source) => {
38
- for (const key in source) {
39
- if (source[key] instanceof Object && key in target) {
40
- Object.assign(source[key], deepMerge(target[key], source[key]));
41
- }
40
+
41
+ /** Keys are stored without CSS leading `--`; product usage uses `--pf-t--…` custom properties. */
42
+ const toCssCustomPropName = (name) => {
43
+ if (name == null || typeof name !== 'string') {
44
+ return name;
42
45
  }
43
- return Object.assign(target || {}, source);
46
+ return name.startsWith('--') ? name : `--${name}`;
44
47
  };
45
48
 
46
49
  const getTokensFromJson = (tokenJson) => {
47
- // parse tokens from json, convert from modules, merge into single allTokens obj
50
+ if (!tokenJson || typeof tokenJson !== 'object') {
51
+ return {};
52
+ }
53
+
48
54
  const themesArr = Object.keys(tokenJson);
49
- const themesObj = themesArr.reduce((acc, cur) => {
50
- acc[cur] = JSON.parse(JSON.stringify(tokenJson[cur]));
51
- return acc;
52
- }, {});
53
- return deepMerge(...Object.values(themesObj));
54
- };
55
+ const allTokens = {};
55
56
 
56
- const getTokenChain = (themeTokenData) => {
57
- let tokenChain = [];
58
- // Palette & some color tokens don't have references but we still want to still show their values
59
- if (!themeTokenData?.references?.[0]) {
60
- tokenChain.push(themeTokenData.value);
61
- } else {
62
- let referenceToken = themeTokenData?.references?.[0];
63
- while (referenceToken && referenceToken !== undefined) {
64
- tokenChain = [...tokenChain, referenceToken.name];
65
- if (referenceToken?.references?.[0]) {
66
- referenceToken = referenceToken?.references?.[0];
57
+ const mergeThemeData = (themeName, source, target) => {
58
+ if (!source || typeof source !== 'object') {
59
+ return;
60
+ }
61
+
62
+ Object.entries(source).forEach(([key, value]) => {
63
+ // Check for theme-specific value objects (e.g., { default: ... } or { dark: ... })
64
+ const hasThemeKey =
65
+ value &&
66
+ typeof value === 'object' &&
67
+ !Array.isArray(value) &&
68
+ (Object.hasOwn(value, 'default') || Object.hasOwn(value, 'dark')) &&
69
+ Object.keys(value).length === 1;
70
+
71
+ if (hasThemeKey) {
72
+ const themeValue = value.default ?? value.dark;
73
+ target[key] = target[key] || {};
74
+ target[key][themeName] = themeValue;
75
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
76
+ target[key] = target[key] || {};
77
+ mergeThemeData(themeName, value, target[key]);
67
78
  } else {
68
- tokenChain.push(referenceToken.value);
69
- break;
79
+ target[key] = target[key] || {};
80
+ target[key][themeName] = value;
70
81
  }
71
- }
72
- }
73
- return tokenChain;
74
- };
82
+ });
83
+ };
75
84
 
76
- const showTokenChain = (themeTokenData, hasReferences) => {
77
- // Show final value if isColorToken but no references - otherwise color value not displayed in table
78
- const tokenChain = hasReferences ? getTokenChain(themeTokenData) : [themeTokenData.value];
79
- return (
80
- <div>
81
- {tokenChain.map((nextValue, index) => (
82
- <div
83
- key={`${index}`}
84
- style={{
85
- padding: `4px 0 4px calc(${c_expandable_section_m_display_lg_PaddingInlineStart.value} * ${index})`
86
- }}
87
- >
88
- <LevelUpAltIcon style={{ transform: 'rotate(90deg)' }} />
89
- <span style={{ paddingInlineStart: c_expandable_section_m_display_lg_PaddingInlineStart.value }}>
90
- {nextValue}
91
- </span>
92
- </div>
93
- ))}
94
- </div>
95
- );
85
+ themesArr.forEach((themeName) => {
86
+ // Read-only merge into a fresh tree; no deep clone theme JSON modules are treated as immutable.
87
+ mergeThemeData(themeName, tokenJson[themeName], allTokens);
88
+ });
89
+
90
+ return allTokens;
96
91
  };
97
92
 
98
93
  const isSearchMatch = (searchValue, tokenName, tokenData) => {
@@ -100,182 +95,1085 @@ const isSearchMatch = (searchValue, tokenName, tokenData) => {
100
95
  if (searchValue === '') {
101
96
  return true;
102
97
  }
98
+
99
+ if (!tokenData || typeof tokenData !== 'object') {
100
+ return false;
101
+ }
102
+
103
103
  // match search term to token name, value, and description
104
104
  searchValue = searchValue.toLowerCase();
105
105
  return (
106
106
  tokenName.toLowerCase().includes(searchValue) ||
107
- Object.entries(tokenData).some(
108
- ([_themeName, themeData]) =>
109
- themeData?.value?.toString().toLowerCase().includes(searchValue) ||
110
- themeData?.description?.toLowerCase().includes(searchValue)
111
- )
107
+ Object.entries(tokenData).some(([_themeName, themeData]) => {
108
+ if (!themeData || typeof themeData !== 'object') {
109
+ return false;
110
+ }
111
+
112
+ // Check if value matches (convert to string first)
113
+ const valueMatch =
114
+ themeData.value !== undefined &&
115
+ themeData.value !== null &&
116
+ String(themeData.value).toLowerCase().includes(searchValue);
117
+
118
+ // Check if description matches (only if it's a string)
119
+ const descriptionMatch =
120
+ typeof themeData.description === 'string' &&
121
+ themeData.description.toLowerCase().includes(searchValue);
122
+
123
+ return valueMatch || descriptionMatch;
124
+ })
112
125
  );
113
126
  };
114
127
 
115
128
  const getFilteredTokens = (tokensArr, searchVal) =>
116
129
  tokensArr.filter(([tokenName, tokenData]) => isSearchMatch(searchVal, tokenName, tokenData));
117
130
 
118
- const getIsColor = (value) => /^(#|rgb)/.test(value);
131
+ /**
132
+ * Build reverse reference map: which tokens use each base/palette token
133
+ * @returns {Object} - { tokenName: [{ category, tokenName }, ...] }
134
+ */
135
+ const buildReferenceMap = (mergedTokens) => {
136
+ const referenceMap = {};
137
+
138
+ const searchTokens = (tokens, categoryName) => {
139
+ Object.entries(tokens).forEach(([key, value]) => {
140
+ if (!value || typeof value !== 'object') {
141
+ return;
142
+ }
143
+
144
+ // Check if this is a token with theme data
145
+ if (isTokenWithThemeData(value)) {
146
+ // This is a token - check its references
147
+ Object.values(value).forEach(themeData => {
148
+ if (themeData?.references) {
149
+ themeData.references.forEach(ref => {
150
+ if (ref.name) {
151
+ if (!referenceMap[ref.name]) {
152
+ referenceMap[ref.name] = [];
153
+ }
154
+ // Use just the token name (key), not the full path
155
+ // Avoid duplicates
156
+ if (!referenceMap[ref.name].some(r => r.category === categoryName && r.tokenName === key)) {
157
+ referenceMap[ref.name].push({
158
+ category: categoryName,
159
+ tokenName: key
160
+ });
161
+ }
162
+ }
163
+ });
164
+ }
165
+ });
166
+ } else {
167
+ // Recurse into nested structure
168
+ searchTokens(value, categoryName);
169
+ }
170
+ });
171
+ };
172
+
173
+ // Search semantic, chart, and base categories for references
174
+ ['semantic', 'chart', 'base'].forEach(category => {
175
+ if (mergedTokens[category]) {
176
+ searchTokens(mergedTokens[category], category);
177
+ }
178
+ });
179
+
180
+ return referenceMap;
181
+ };
182
+
183
+ /**
184
+ * Search across all categories and return results grouped by category
185
+ * @returns {Object} - { categoryName: [...tokens], ... } with only categories that have matches
186
+ */
187
+ const getFilteredTokensByCategory = (mergedTokens, allCategoriesArr, searchVal) => {
188
+ if (!searchVal || searchVal.trim() === '') {
189
+ return {};
190
+ }
191
+
192
+ const resultsByCategory = {};
193
+
194
+ allCategoriesArr.forEach((category) => {
195
+ const categoryTokens = mergedTokens[category];
196
+ if (!categoryTokens) {
197
+ return;
198
+ }
199
+
200
+ const categoryTokensArr = getCategoryTokensArr(category, categoryTokens);
201
+ const matches = getFilteredTokens(categoryTokensArr, searchVal);
202
+
203
+ if (matches.length > 0) {
204
+ resultsByCategory[category] = matches;
205
+ }
206
+ });
207
+
208
+ return resultsByCategory;
209
+ };
210
+
211
+ const getIsColor = (value) => {
212
+ // Extract the actual value if it's nested in an object
213
+ const actualValue = (value && typeof value === 'object' && value.default) ? value.default : value;
214
+ return /^(#|rgb)/.test(actualValue);
215
+ };
216
+
217
+ /**
218
+ * Check if a value object represents a token with theme data (has references or value properties)
219
+ */
220
+ const isTokenWithThemeData = (value) =>
221
+ value && typeof value === 'object' &&
222
+ Object.values(value).some(v => v && typeof v === 'object' && (v.references || v.value));
223
+
119
224
 
120
225
  const getCategoryTokensArr = (selectedCategory, categoryTokens) => {
226
+ if (!categoryTokens || typeof categoryTokens !== 'object') {
227
+ return [];
228
+ }
229
+
121
230
  // Create array of all tokens/nested tokens in selectedCategory
122
231
  let categoryTokensArr = [];
123
- if (!['base', 'semantic'].includes(selectedCategory)) {
232
+ const NESTED_CATEGORIES = ['base', 'semantic'];
233
+
234
+ if (!NESTED_CATEGORIES.includes(selectedCategory)) {
124
235
  categoryTokensArr = Object.entries(categoryTokens);
125
- } else if (['base', 'semantic'].includes(selectedCategory)) {
236
+ } else {
126
237
  // base/semantic combine nested subcategory tokens into flattened arr
127
- for (var subCategory in categoryTokens) {
128
- categoryTokensArr.push(...Object.entries(categoryTokens[subCategory]));
238
+ for (const subCategory in categoryTokens) {
239
+ if (Object.hasOwn(categoryTokens, subCategory)) {
240
+ const subCategoryTokens = categoryTokens[subCategory];
241
+ if (subCategoryTokens && typeof subCategoryTokens === 'object') {
242
+ categoryTokensArr.push(...Object.entries(subCategoryTokens));
243
+ }
244
+ }
129
245
  }
130
246
  }
131
247
  return categoryTokensArr;
132
248
  };
133
249
 
250
+ /**
251
+ * Theme select order and "All themes" value rows: Default family (Light: DC, HC, Gl; then Dark: …),
252
+ * then Unified family the same. Matches ThemeDisplayLabel column semantics.
253
+ */
254
+ const THEME_OPTION_ORDER = [
255
+ 'default',
256
+ 'highcontrast',
257
+ 'glass',
258
+ 'dark',
259
+ 'highcontrast-dark',
260
+ 'glass-dark',
261
+ 'redhat',
262
+ 'redhat-highcontrast',
263
+ 'redhat-glass',
264
+ 'redhat-dark',
265
+ 'redhat-highcontrast-dark',
266
+ 'redhat-glass-dark'
267
+ ];
268
+
269
+ const THEME_OPTION_ORDER_INDEX = Object.fromEntries(THEME_OPTION_ORDER.map((name, index) => [name, index]));
270
+
271
+ const compareThemeNames = (a, b) => {
272
+ const ai = THEME_OPTION_ORDER_INDEX[a];
273
+ const bi = THEME_OPTION_ORDER_INDEX[b];
274
+ const aKnown = ai !== undefined;
275
+ const bKnown = bi !== undefined;
276
+ if (!aKnown && !bKnown) {
277
+ return a.localeCompare(b);
278
+ }
279
+ if (!aKnown) {
280
+ return 1;
281
+ }
282
+ if (!bKnown) {
283
+ return -1;
284
+ }
285
+ return ai - bi;
286
+ };
287
+
288
+ const getThemeEntriesForDisplay = (tokenData, selectedTheme, exhibitsThemeVariantValues, allAvailableThemes) => {
289
+ if (!tokenData || typeof tokenData !== 'object') {
290
+ return [];
291
+ }
292
+
293
+ const normalizeThemeValue = (themeValue) => {
294
+ if (!themeValue) {
295
+ return themeValue;
296
+ }
297
+ // If it has a default property, use that (for some token structures)
298
+ if (Object.hasOwn(themeValue, 'default')) {
299
+ return themeValue.default;
300
+ }
301
+ // If it's an object with a value property, extract the value
302
+ if (typeof themeValue === 'object' && Object.hasOwn(themeValue, 'value')) {
303
+ return themeValue;
304
+ }
305
+ // Otherwise return as-is
306
+ return themeValue;
307
+ };
308
+
309
+ if (selectedTheme === 'all') {
310
+ if (!exhibitsThemeVariantValues) {
311
+ // For palette tokens, show only one value since they don't vary by theme
312
+ const firstTheme = Object.keys(tokenData)[0];
313
+ if (!firstTheme) {
314
+ return [];
315
+ }
316
+ return [[firstTheme, normalizeThemeValue(tokenData[firstTheme])]];
317
+ }
318
+
319
+ // Group themes by their value (don't sort first - group all matching values together)
320
+ const valueGroups = new Map();
321
+ const themesWithoutValue = [];
322
+
323
+ // Check ALL available themes, not just the ones in tokenData
324
+ const themesToCheck = allAvailableThemes || Object.keys(tokenData);
325
+
326
+ themesToCheck.forEach((themeName) => {
327
+ const themeValue = tokenData[themeName];
328
+ const normalizedValue = normalizeThemeValue(themeValue);
329
+
330
+ // Track themes that don't have a value
331
+ if (!normalizedValue || normalizedValue.value === undefined) {
332
+ themesWithoutValue.push(themeName);
333
+ return;
334
+ }
335
+
336
+ // Create a key from the value for grouping - sort references to ensure consistent key
337
+ const valueKey = JSON.stringify({
338
+ value: normalizedValue?.value,
339
+ references: normalizedValue?.references?.map(r => r.name).sort()
340
+ });
341
+
342
+ if (!valueGroups.has(valueKey)) {
343
+ valueGroups.set(valueKey, {
344
+ themeNames: [],
345
+ themeValue: normalizedValue
346
+ });
347
+ }
348
+ valueGroups.get(valueKey).themeNames.push(themeName);
349
+ });
350
+
351
+ // Build result with theme names sorted within each group
352
+ const result = Array.from(valueGroups.values()).map(({ themeNames, themeValue }) => [
353
+ themeNames.sort(compareThemeNames),
354
+ themeValue
355
+ ]);
356
+
357
+ // Add group for themes without values if any exist
358
+ if (themesWithoutValue.length > 0) {
359
+ result.push([themesWithoutValue.sort(compareThemeNames), null]);
360
+ }
361
+
362
+ return result;
363
+ }
364
+
365
+ const themeValue = tokenData[selectedTheme];
366
+
367
+ // If theme doesn't exist for this token, return null to indicate no value
368
+ if (!themeValue) {
369
+ return [[selectedTheme, null]];
370
+ }
371
+
372
+ const tokenValue = normalizeThemeValue(themeValue) || {
373
+ value: '',
374
+ description: tokenData[Object.keys(tokenData)[0]]?.description || '',
375
+ references: []
376
+ };
377
+ return [[selectedTheme, tokenValue]];
378
+ };
379
+
380
+ const getTokenChain = (themeTokenData) => {
381
+ if (!themeTokenData || typeof themeTokenData !== 'object') {
382
+ return [];
383
+ }
384
+
385
+ const tokenChain = [];
386
+ const MAX_CHAIN_DEPTH = 50; // Prevent infinite loops
387
+ let depth = 0;
388
+
389
+ if (!themeTokenData.references?.[0]) {
390
+ const value = themeTokenData.value;
391
+ if (value !== undefined && value !== null) {
392
+ tokenChain.push(value);
393
+ }
394
+ return tokenChain;
395
+ }
396
+
397
+ let referenceToken = themeTokenData.references[0];
398
+ while (referenceToken && depth < MAX_CHAIN_DEPTH) {
399
+ depth++;
400
+
401
+ if (referenceToken.name) {
402
+ tokenChain.push(referenceToken.name);
403
+ }
404
+
405
+ if (referenceToken.references?.[0]) {
406
+ referenceToken = referenceToken.references[0];
407
+ } else {
408
+ if (referenceToken.value !== undefined && referenceToken.value !== null) {
409
+ tokenChain.push(referenceToken.value);
410
+ }
411
+ break;
412
+ }
413
+ }
414
+
415
+ return tokenChain;
416
+ };
417
+
418
+ const findTokenCategory = (tokenName, mergedTokens) => {
419
+ // Search through categories to find which one contains this token
420
+ for (const category of ['semantic', 'chart', 'base', 'palette']) {
421
+ if (!mergedTokens[category]) continue;
422
+
423
+ const searchInCategory = (tokens) => {
424
+ for (const [key, value] of Object.entries(tokens)) {
425
+ if (key === tokenName) {
426
+ return true;
427
+ }
428
+ // Check if it's a nested structure
429
+ if (value && typeof value === 'object') {
430
+ if (!isTokenWithThemeData(value) && searchInCategory(value)) {
431
+ return true;
432
+ }
433
+ }
434
+ }
435
+ return false;
436
+ };
437
+
438
+ if (searchInCategory(mergedTokens[category])) {
439
+ return category;
440
+ }
441
+ }
442
+ return null;
443
+ };
444
+
445
+ const showTokenChain = (themeTokenData, hasReferences, onNavigate, mergedTokens) => {
446
+ const tokenChain = hasReferences ? getTokenChain(themeTokenData) : [themeTokenData.value];
447
+
448
+ return (
449
+ <div>
450
+ {tokenChain.map((nextValue, index) => {
451
+ // Check if this value is a token name (starts with pf-t--)
452
+ const isTokenReference = typeof nextValue === 'string' && nextValue.startsWith('pf-t--');
453
+ const category = isTokenReference && mergedTokens ? findTokenCategory(nextValue, mergedTokens) : null;
454
+
455
+ return (
456
+ <div
457
+ key={`${index}`}
458
+ className="ws-token-chain-item"
459
+ style={{
460
+ padding: `4px 0 4px calc(${c_expandable_section_m_display_lg_PaddingInlineStart.value} * ${index})`
461
+ }}
462
+ >
463
+ <LevelUpAltIcon style={{ transform: 'rotate(90deg)' }} />
464
+ <span
465
+ className="ws-token-chain-value"
466
+ style={{ paddingInlineStart: c_expandable_section_m_display_lg_PaddingInlineStart.value }}
467
+ >
468
+ {isTokenReference && category && onNavigate ? (
469
+ <Button
470
+ variant="link"
471
+ isInline
472
+ onClick={() => onNavigate(category, nextValue)}
473
+ className="ws-token-chain-button"
474
+ >
475
+ {capitalize(category)}: {toCssCustomPropName(nextValue)}
476
+ </Button>
477
+ ) : (
478
+ nextValue
479
+ )}
480
+ </span>
481
+ </div>
482
+ );
483
+ })}
484
+ </div>
485
+ );
486
+ };
487
+
134
488
  {
135
489
  /* Components */
136
490
  }
137
- const TokenChain = ({ tokenThemesArr }) => (
138
- <Tr isExpanded>
139
- <Td />
140
- <Td colSpan={3}>
141
- <ExpandableRowContent>
142
- <Grid hasGutter>
143
- {tokenThemesArr.map(([themeName, themeToken]) => (
144
- <React.Fragment key={themeName}>
145
- <GridItem span={2}>{capitalize(themeName)}:</GridItem>
146
- <GridItem span={10}>{showTokenChain(themeToken)}</GridItem>
147
- </React.Fragment>
148
- ))}
149
- </Grid>
150
- </ExpandableRowContent>
151
- </Td>
152
- </Tr>
153
- );
491
+ const TokenDerivationPopoverBody = ({ themeName, themeToken, showThemeLabel, exhibitsThemeVariantValues, onNavigate, mergedTokens }) => {
492
+ const hasReferences = Boolean(themeToken?.references?.[0]);
493
+ const themeNames = Array.isArray(themeName) ? themeName : [themeName];
494
+ const isGrouped = themeNames.length > 1;
154
495
 
155
- const TokenValue = ({ themeName, themeToken, tokenName }) => {
156
- const isColor = getIsColor(themeToken.value);
157
496
  return (
497
+ <ThemeLabelAbbrevContext.Provider value={false}>
498
+ <div className="ws-token-derivation-popover">
499
+ {exhibitsThemeVariantValues && showThemeLabel && (
500
+ <div className="ws-token-derivation-popover__theme">
501
+ {isGrouped ? (
502
+ <div>
503
+ <strong>{themeNames.length} themes:</strong>
504
+ <div style={{ marginBlockStart: 'var(--pf-t--global--spacer--xs)' }}>
505
+ {themeNames.map((name) => (
506
+ <div key={name}>
507
+ <ThemeDisplayLabel themeName={name} />
508
+ </div>
509
+ ))}
510
+ </div>
511
+ </div>
512
+ ) : (
513
+ <ThemeDisplayLabel themeName={themeNames[0]} />
514
+ )}
515
+ </div>
516
+ )}
517
+ {showTokenChain(themeToken, hasReferences, onNavigate, mergedTokens)}
518
+ </div>
519
+ </ThemeLabelAbbrevContext.Provider>
520
+ );
521
+ };
522
+
523
+ const TokenValue = ({
524
+ themeName,
525
+ themeToken,
526
+ tokenName,
527
+ showThemeLabel,
528
+ showDerivationPopover,
529
+ exhibitsThemeVariantValues,
530
+ selectedTheme,
531
+ onNavigate,
532
+ mergedTokens
533
+ }) => {
534
+ // Handle null themeToken (theme doesn't exist for this token)
535
+ if (themeToken === null) {
536
+ const themeNames = Array.isArray(themeName) ? themeName : [themeName];
537
+ const themeKey = themeNames.join('-');
538
+ const isGrouped = themeNames.length > 1;
539
+
540
+ return (
541
+ <div className="ws-token-value-line" key={`${themeKey}-${tokenName}`}>
542
+ <ThemeLabelAbbrevContext.Provider value={selectedTheme === 'all'}>
543
+ <Flex
544
+ className="ws-token-value-line-inner"
545
+ direction={{ default: 'row' }}
546
+ alignItems={{ default: 'alignItemsCenter' }}
547
+ flexWrap={{ default: 'nowrap' }}
548
+ spaceItems={{ default: 'spaceItemsSm' }}
549
+ >
550
+ {showThemeLabel && (
551
+ <FlexItem className="ws-theme-label-inline">
552
+ {isGrouped ? (
553
+ <Tooltip
554
+ content={
555
+ <ThemeLabelAbbrevContext.Provider value={false}>
556
+ <div>
557
+ {themeNames.map((name) => (
558
+ <div key={name}>
559
+ <ThemeDisplayLabel themeName={name} />
560
+ </div>
561
+ ))}
562
+ </div>
563
+ </ThemeLabelAbbrevContext.Provider>
564
+ }
565
+ position="top"
566
+ maxWidth="600px"
567
+ >
568
+ <Button variant="plain" className="ws-theme-group-label" tabIndex={-1}>
569
+ {themeNames.length} themes
570
+ </Button>
571
+ </Tooltip>
572
+ ) : (
573
+ <ThemeDisplayLabel themeName={themeNames[0]} />
574
+ )}
575
+ </FlexItem>
576
+ )}
577
+ <FlexItem
578
+ className={
579
+ showThemeLabel ? 'ws-token-value-main ws-token-value-main--separated' : 'ws-token-value-main'
580
+ }
581
+ >
582
+ <span style={{ color: 'var(--pf-t--global--text--color--subtle)' }}>—</span>
583
+ </FlexItem>
584
+ </Flex>
585
+ </ThemeLabelAbbrevContext.Provider>
586
+ </div>
587
+ );
588
+ }
589
+
590
+ if (!themeToken || typeof themeToken !== 'object') {
591
+ if (process.env.NODE_ENV === 'development') {
592
+ console.warn(`TokenValue: Invalid themeToken for ${tokenName}`);
593
+ }
594
+ return null;
595
+ }
596
+
597
+ // Extract the actual value from token.value if it's nested in an object
598
+ let displayValue = themeToken.value;
599
+
600
+ if (displayValue && typeof displayValue === 'object' && !Array.isArray(displayValue)) {
601
+ // If value is an object, try to extract a string value
602
+ const values = Object.values(displayValue);
603
+ if (values.length > 0 && typeof values[0] === 'string') {
604
+ displayValue = values[0];
605
+ } else {
606
+ // Cannot extract a valid string value
607
+ if (process.env.NODE_ENV === 'development') {
608
+ console.warn(`Unable to extract displayValue for token ${tokenName}:`, displayValue);
609
+ }
610
+ return null;
611
+ }
612
+ }
613
+
614
+ // Ensure displayValue is a primitive type suitable for rendering
615
+ if (typeof displayValue === 'object' || displayValue === undefined || displayValue === null) {
616
+ if (process.env.NODE_ENV === 'development') {
617
+ console.warn(`DisplayValue is invalid for ${tokenName}:`, typeof displayValue);
618
+ }
619
+ return null;
620
+ }
621
+
622
+ // Convert to string if needed
623
+ displayValue = String(displayValue);
624
+
625
+ const isColor = getIsColor(themeToken.value);
626
+ const valueMain = isColor ? (
158
627
  <Flex
159
- justifyContent={{ default: 'justify-content-space-between' }}
628
+ direction={{ default: 'row' }}
629
+ alignItems={{ default: 'alignItemsCenter' }}
160
630
  flexWrap={{ default: 'nowrap' }}
161
- key={`${themeName}-${tokenName}`}
631
+ spaceItems={{ default: 'spaceItemsSm' }}
162
632
  >
163
- <FlexItem>{capitalize(themeName)}:</FlexItem>
164
- {isColor ? (
165
- <FlexItem key={`${themeName}_${tokenName}_swatch`} className="pf-v6-l-flex pf-m-column pf-m-align-self-center">
166
- <span className="ws-token-swatch" style={{ backgroundColor: themeToken.value }} />
167
- </FlexItem>
633
+ <FlexItem className="ws-token-value-hex-wrap">
634
+ <span className="ws-token-value-hex">{displayValue}</span>
635
+ </FlexItem>
636
+ <FlexItem className="ws-token-swatch-wrap">
637
+ <span className="ws-token-swatch" style={{ backgroundColor: displayValue }} />
638
+ </FlexItem>
639
+ </Flex>
640
+ ) : (
641
+ <span className="ws-token-value-plain-inline">{displayValue}</span>
642
+ );
643
+
644
+ const valueFlexItem = (
645
+ <FlexItem
646
+ className={
647
+ showThemeLabel ? 'ws-token-value-main ws-token-value-main--separated' : 'ws-token-value-main'
648
+ }
649
+ >
650
+ {showDerivationPopover ? (
651
+ <Popover
652
+ aria-label={`How ${toCssCustomPropName(tokenName)} is derived for this theme`}
653
+ headerContent={toCssCustomPropName(tokenName)}
654
+ bodyContent={
655
+ <TokenDerivationPopoverBody
656
+ themeName={themeName}
657
+ themeToken={themeToken}
658
+ showThemeLabel={showThemeLabel}
659
+ exhibitsThemeVariantValues={exhibitsThemeVariantValues}
660
+ onNavigate={onNavigate}
661
+ mergedTokens={mergedTokens}
662
+ />
663
+ }
664
+ position="bottom"
665
+ minWidth="400px"
666
+ >
667
+ <Button
668
+ variant="plain"
669
+ className="ws-token-value-popover-trigger"
670
+ aria-label={`${displayValue}. Show how ${toCssCustomPropName(tokenName)} is derived`}
671
+ aria-haspopup="dialog"
672
+ >
673
+ {valueMain}
674
+ </Button>
675
+ </Popover>
168
676
  ) : (
169
- <div className="pf-v6-l-flex pf-m-column pf-m-align-self-center">{themeToken.value}</div>
677
+ <span tabIndex={0}>{valueMain}</span>
170
678
  )}
171
- </Flex>
679
+ </FlexItem>
172
680
  );
173
- };
174
681
 
175
- const TokensTableBody = ({ token, expandedTokens, setExpanded, isSemanticLayer, rowIndex }) => {
176
- const [tokenName, tokenData] = token;
177
- const tokenThemesArr = Object.entries(tokenData);
178
- const isExpandable = tokenThemesArr.some(
179
- ([_themeName, themeToken]) => themeToken.hasOwnProperty('references') || getIsColor(themeToken.value)
682
+ // Handle themeName being either a string or array of strings (when grouped by value)
683
+ const themeNames = Array.isArray(themeName) ? themeName : [themeName];
684
+ const themeKey = themeNames.join('-');
685
+ const isGrouped = themeNames.length > 1;
686
+
687
+ return (
688
+ <div className="ws-token-value-line" key={`${themeKey}-${tokenName}`}>
689
+ <ThemeLabelAbbrevContext.Provider value={selectedTheme === 'all'}>
690
+ <Flex
691
+ className="ws-token-value-line-inner"
692
+ direction={{ default: 'row' }}
693
+ alignItems={{ default: 'alignItemsCenter' }}
694
+ flexWrap={{ default: 'nowrap' }}
695
+ spaceItems={{ default: 'spaceItemsSm' }}
696
+ >
697
+ {showThemeLabel && (
698
+ <FlexItem className="ws-theme-label-inline">
699
+ {isGrouped ? (
700
+ // Show count for grouped themes with tooltip showing full names
701
+ <Tooltip
702
+ content={
703
+ <ThemeLabelAbbrevContext.Provider value={false}>
704
+ <div>
705
+ {themeNames.map((name) => (
706
+ <div key={name}>
707
+ <ThemeDisplayLabel themeName={name} />
708
+ </div>
709
+ ))}
710
+ </div>
711
+ </ThemeLabelAbbrevContext.Provider>
712
+ }
713
+ position="top"
714
+ minWidth="400px"
715
+ maxWidth="600px"
716
+ >
717
+ <Button variant="plain" className="ws-theme-group-label" tabIndex={-1}>
718
+ {themeNames.length} themes
719
+ </Button>
720
+ </Tooltip>
721
+ ) : (
722
+ // ThemeDisplayLabel already handles tooltips when abbreviated
723
+ <ThemeDisplayLabel themeName={themeNames[0]} />
724
+ )}
725
+ </FlexItem>
726
+ )}
727
+ {valueFlexItem}
728
+ </Flex>
729
+ </ThemeLabelAbbrevContext.Provider>
730
+ </div>
180
731
  );
181
- const isTokenExpanded = expandedTokens.includes(tokenName);
182
- const tokenDescription = tokenThemesArr[0][1].description;
732
+ };
733
+
734
+ const OtherCategoryResults = ({ otherResults, onCategoryClick }) => {
735
+ if (Object.keys(otherResults).length === 0) {
736
+ return null;
737
+ }
183
738
 
184
739
  return (
185
- <Tbody isExpanded={isTokenExpanded}>
186
- <Tr>
187
- {/* Expandable Row icon */}
188
- <Td
189
- expand={
190
- isExpandable && {
191
- rowIndex,
192
- isExpanded: isTokenExpanded,
193
- onToggle: () => setExpanded(tokenName, !isTokenExpanded),
194
- expandId: `${tokenName}-expandable-toggle`
195
- }
196
- }
197
- />
198
- <Td>
199
- <code>{tokenName}</code>
200
- </Td>
201
- {/* Token values for each theme */}
202
- <Td>
203
- {tokenThemesArr.map(([themeName, themeToken]) => (
204
- <TokenValue themeName={themeName} themeToken={themeToken} tokenName={tokenName} key={themeName} />
740
+ <div style={{
741
+ marginBlockStart: 'var(--pf-t--global--spacer--md)',
742
+ marginBlockEnd: 'var(--pf-t--global--spacer--md)',
743
+ paddingBlock: 'var(--pf-t--global--spacer--sm)',
744
+ paddingInline: 'var(--pf-t--global--spacer--sm)',
745
+ backgroundColor: 'var(--pf-t--global--background--color--secondary--default)',
746
+ borderRadius: 'var(--pf-t--global--border--radius--small)',
747
+ border: 'var(--pf-t--global--border--width--regular) solid var(--pf-t--global--border--color--default)'
748
+ }}>
749
+ <strong>Also found in: </strong>
750
+ {Object.entries(otherResults).map(([category, tokens], index) => (
751
+ <React.Fragment key={category}>
752
+ {index > 0 && ', '}
753
+ <Button
754
+ variant="link"
755
+ isInline
756
+ onClick={() => onCategoryClick(category)}
757
+ >
758
+ {capitalize(category)} ({tokens.length})
759
+ </Button>
760
+ </React.Fragment>
761
+ ))}
762
+ </div>
763
+ );
764
+ };
765
+
766
+ const UsedByCell = ({ tokenName, referenceMap, onNavigate }) => {
767
+ const references = referenceMap[tokenName] || [];
768
+
769
+ if (references.length === 0) {
770
+ return <Td>—</Td>;
771
+ }
772
+
773
+ if (references.length <= 3) {
774
+ return (
775
+ <Td>
776
+ <Flex direction={{ default: 'column' }} rowGap={{ default: 'gapSm' }}>
777
+ {references.map((ref, index) => (
778
+ <FlexItem key={index}>
779
+ <Button
780
+ variant="link"
781
+ isInline
782
+ onClick={() => onNavigate(ref.category, ref.tokenName)}
783
+ >
784
+ {capitalize(ref.category)}: {toCssCustomPropName(ref.tokenName)}
785
+ </Button>
786
+ </FlexItem>
205
787
  ))}
206
- </Td>
788
+ </Flex>
789
+ </Td>
790
+ );
791
+ }
792
+
793
+ // 4+ references: show count with popover
794
+ return (
795
+ <Td>
796
+ <Popover
797
+ headerContent={`Used by ${references.length} tokens`}
798
+ bodyContent={
799
+ <Flex direction={{ default: 'column' }} rowGap={{ default: 'gapSm' }}>
800
+ {references.map((ref, index) => (
801
+ <FlexItem key={index}>
802
+ <Button
803
+ variant="link"
804
+ isInline
805
+ onClick={() => onNavigate(ref.category, ref.tokenName)}
806
+ >
807
+ {capitalize(ref.category)}: {toCssCustomPropName(ref.tokenName)}
808
+ </Button>
809
+ </FlexItem>
810
+ ))}
811
+ </Flex>
812
+ }
813
+ position="left"
814
+ minWidth="400px"
815
+ >
816
+ <Button variant="link" isInline aria-haspopup="dialog">
817
+ {references.length} tokens
818
+ </Button>
819
+ </Popover>
820
+ </Td>
821
+ );
822
+ };
207
823
 
208
- {/* Description - only for semantic tokens */}
209
- {isSemanticLayer && <Td>{tokenDescription}</Td>}
210
- </Tr>
824
+ const TokensTableBody = ({
825
+ token,
826
+ isSemanticLayer,
827
+ isChartLayer,
828
+ isBaseLayer,
829
+ isPaletteLayer,
830
+ exhibitsThemeVariantValues,
831
+ selectedTheme,
832
+ referenceMap,
833
+ onNavigate,
834
+ mergedTokens,
835
+ allAvailableThemes
836
+ }) => {
837
+ const [tokenName, tokenData] = token;
838
+ const tokenThemesArr = getThemeEntriesForDisplay(tokenData, selectedTheme, exhibitsThemeVariantValues, allAvailableThemes);
839
+ const tokenDescription = tokenThemesArr[0]?.[1]?.description || '';
840
+ const showUsedBy = isBaseLayer || isPaletteLayer;
211
841
 
212
- {/* Expandable token chain */}
213
- {isTokenExpanded && <TokenChain tokenThemesArr={tokenThemesArr} />}
214
- </Tbody>
842
+ return (
843
+ <Tr>
844
+ <Td dataLabel="Name">
845
+ <code tabIndex={0}>{toCssCustomPropName(tokenName)}</code>
846
+ </Td>
847
+ <Td className="tokens-table-value-cell">
848
+ <Flex className="tokens-table-value-stack" direction={{ default: 'column' }} rowGap={{ default: 'gapMd' }}>
849
+ {tokenThemesArr.map(([themeName, themeToken], index) => {
850
+ // themeName can be a string or array of strings (when grouped by value)
851
+ const key = Array.isArray(themeName) ? themeName.join('-') : themeName;
852
+ return (
853
+ <FlexItem key={key}>
854
+ <TokenValue
855
+ themeName={themeName}
856
+ themeToken={themeToken}
857
+ tokenName={tokenName}
858
+ showThemeLabel={exhibitsThemeVariantValues && selectedTheme === 'all'}
859
+ exhibitsThemeVariantValues={exhibitsThemeVariantValues}
860
+ selectedTheme={selectedTheme}
861
+ showDerivationPopover={
862
+ (isSemanticLayer || isChartLayer || isBaseLayer) &&
863
+ (Boolean(themeToken?.references?.[0]) || getIsColor(themeToken?.value))
864
+ }
865
+ onNavigate={onNavigate}
866
+ mergedTokens={mergedTokens}
867
+ />
868
+ </FlexItem>
869
+ );
870
+ })}
871
+ </Flex>
872
+ </Td>
873
+ {isSemanticLayer && <Td>{tokenDescription}</Td>}
874
+ {showUsedBy && <UsedByCell tokenName={tokenName} referenceMap={referenceMap} onNavigate={onNavigate} />}
875
+ </Tr>
215
876
  );
216
877
  };
217
878
 
218
879
  export const TokensTable = ({ tokenJson }) => {
880
+ // Initialize state from URL params if present
881
+ const getInitialStateFromURL = () => {
882
+ if (typeof window === 'undefined') {
883
+ return { category: 'semantic', search: '' };
884
+ }
885
+ const params = new URLSearchParams(window.location.search);
886
+ return {
887
+ category: params.get('category') || 'semantic',
888
+ search: params.get('token') || ''
889
+ };
890
+ };
891
+
892
+ const initialState = React.useMemo(getInitialStateFromURL, []);
893
+
894
+ // Track if we're currently handling browser navigation to avoid pushState loops
895
+ const isNavigatingRef = React.useRef(false);
896
+
219
897
  // state variables
220
- const [searchValue, setSearchValue] = React.useState('');
221
- const [expandedTokens, setExpandedTokens] = React.useState([]);
222
- const [selectedCategory, setSelectedCategory] = React.useState('semantic');
898
+ const [searchValue, setSearchValue] = React.useState(initialState.search);
899
+ const [selectedCategory, setSelectedCategory] = React.useState(initialState.category);
900
+ // All categories share the same theme selection except palette (which is always 'all')
901
+ const [sharedTheme, setSharedTheme] = React.useState('default');
902
+ const [page, setPage] = React.useState(1);
903
+ const [perPage, setPerPage] = React.useState(20);
904
+
905
+ // Palette always uses 'all', other categories use shared theme
906
+ const selectedTheme = selectedCategory === 'palette' ? 'all' : sharedTheme;
907
+
908
+ // Memoize sorted theme names to avoid creating new arrays on every render
909
+ const sortedThemeNames = React.useMemo(
910
+ () => Object.keys(tokenJson).sort(compareThemeNames),
911
+ [tokenJson]
912
+ );
913
+
914
+ // Per-theme JSON module references are stable across MDX re-renders
915
+ const mergeDepList = React.useMemo(
916
+ () => sortedThemeNames.map((themeName) => tokenJson[themeName]),
917
+ [sortedThemeNames, tokenJson]
918
+ );
919
+
920
+ const mergedTokens = React.useMemo(() => getTokensFromJson(tokenJson), mergeDepList);
921
+
922
+ const themeOptions = React.useMemo(() => ['all', ...sortedThemeNames], [sortedThemeNames]);
923
+
924
+ // List of all available themes (excluding 'all')
925
+ const allAvailableThemes = React.useMemo(() => sortedThemeNames, [sortedThemeNames]);
926
+
927
+ // Build reference map for "Used by" column
928
+ const referenceMap = React.useMemo(() => buildReferenceMap(mergedTokens), [mergedTokens]);
929
+
930
+ const deferredSearchValue = React.useDeferredValue(searchValue);
931
+
932
+ const tokenCategoryOrder = ['chart', 'semantic', 'base', 'palette'];
933
+ const allCategoryKeys = React.useMemo(
934
+ () => Object.keys(mergedTokens).filter((c) => c !== 'default'),
935
+ [mergedTokens]
936
+ );
937
+ const allCategoriesArr = React.useMemo(
938
+ () => [
939
+ ...tokenCategoryOrder.filter((c) => allCategoryKeys.includes(c)),
940
+ ...allCategoryKeys.filter((c) => !tokenCategoryOrder.includes(c)).sort()
941
+ ],
942
+ [allCategoryKeys]
943
+ );
223
944
 
224
- const allTokens = getTokensFromJson(tokenJson);
225
945
  const isSemanticLayer = selectedCategory === 'semantic';
226
- const categoryTokens = allTokens[selectedCategory];
227
- // get tokens for selected category
228
- const categoryTokensArr = getCategoryTokensArr(selectedCategory, categoryTokens);
229
- // filter selected category tokens based on search term
230
- const searchResults = getFilteredTokens(categoryTokensArr, searchValue);
231
- // remove extra 'default' category
232
- delete allTokens.default;
233
- const allCategoriesArr = Object.keys(allTokens);
234
-
235
- // helper funcs
236
- const setExpanded = (tokenName, isExpanding = true) =>
237
- setExpandedTokens((prevExpanded) => {
238
- const otherExpandedTokens = prevExpanded.filter((n) => n !== tokenName);
239
- return isExpanding ? [...otherExpandedTokens, tokenName] : otherExpandedTokens;
240
- });
946
+ const isChartLayer = selectedCategory === 'chart';
947
+ const isBaseLayer = selectedCategory === 'base';
948
+ const isPaletteLayer = selectedCategory === 'palette';
949
+ // Base tokens can have theme variants (e.g., high contrast differs), only palette is truly invariant
950
+ const exhibitsThemeVariantValues = selectedCategory === 'semantic' || selectedCategory === 'chart' || selectedCategory === 'base';
951
+ const showUsedByColumn = isBaseLayer || isPaletteLayer;
952
+ const categoryTokens = mergedTokens[selectedCategory];
953
+ const categoryTokensArr = React.useMemo(
954
+ () => getCategoryTokensArr(selectedCategory, categoryTokens),
955
+ [selectedCategory, categoryTokens]
956
+ );
957
+
958
+ // When searching, search across all categories; otherwise just filter current category
959
+ const hasSearchTerm = deferredSearchValue && deferredSearchValue.trim() !== '';
960
+ const searchResultsByCategory = React.useMemo(
961
+ () => (hasSearchTerm ? getFilteredTokensByCategory(mergedTokens, allCategoriesArr, deferredSearchValue) : {}),
962
+ [mergedTokens, allCategoriesArr, deferredSearchValue, hasSearchTerm]
963
+ );
964
+
965
+ // Always show current category results
966
+ const searchResults = React.useMemo(
967
+ () => getFilteredTokens(categoryTokensArr, deferredSearchValue),
968
+ [categoryTokensArr, deferredSearchValue]
969
+ );
970
+
971
+ // Other categories with results (excluding current category)
972
+ const otherCategoryResults = React.useMemo(() => {
973
+ if (!hasSearchTerm) {
974
+ return {};
975
+ }
976
+ const others = { ...searchResultsByCategory };
977
+ delete others[selectedCategory];
978
+ return others;
979
+ }, [hasSearchTerm, searchResultsByCategory, selectedCategory]);
980
+
981
+ // Memoize results count to prevent SearchInput from losing focus
982
+ const resultsCount = React.useMemo(() => searchResults.length.toString(), [searchResults.length]);
983
+
984
+ // Pagination calculations
985
+ const totalItems = searchResults.length;
986
+ const startIndex = (page - 1) * perPage;
987
+ const endIndex = startIndex + perPage;
988
+ const paginatedResults = React.useMemo(
989
+ () => searchResults.slice(startIndex, endIndex),
990
+ [searchResults, startIndex, endIndex]
991
+ );
992
+
993
+ // Reset to page 1 when search or category changes
994
+ React.useEffect(() => {
995
+ setPage(1);
996
+ }, [deferredSearchValue, selectedCategory]);
997
+
998
+ // Sync URL with state (for shareable URLs and browser back/forward)
999
+ React.useEffect(() => {
1000
+ if (typeof window === 'undefined') {
1001
+ return;
1002
+ }
1003
+
1004
+ // Skip if we're handling browser navigation
1005
+ if (isNavigatingRef.current) {
1006
+ isNavigatingRef.current = false;
1007
+ return;
1008
+ }
1009
+
1010
+ const params = new URLSearchParams(window.location.search);
1011
+ // Normalize params the same way we do in popstate - missing params use defaults
1012
+ const currentCategory = params.get('category') || 'semantic';
1013
+ const currentToken = params.get('token') || '';
1014
+
1015
+ // Only update URL if state actually changed
1016
+ if (currentCategory === selectedCategory && currentToken === searchValue) {
1017
+ return;
1018
+ }
1019
+
1020
+ const newParams = new URLSearchParams();
1021
+ if (selectedCategory !== 'semantic') {
1022
+ newParams.set('category', selectedCategory);
1023
+ }
1024
+ if (searchValue) {
1025
+ newParams.set('token', searchValue);
1026
+ }
1027
+
1028
+ const queryString = newParams.toString();
1029
+ const newUrl = queryString
1030
+ ? `${window.location.pathname}?${queryString}${window.location.hash}`
1031
+ : `${window.location.pathname}${window.location.hash}`;
1032
+
1033
+ window.history.pushState(null, '', newUrl);
1034
+ }, [selectedCategory, searchValue]);
1035
+
1036
+ // Sync state when URL changes (browser back/forward)
1037
+ React.useEffect(() => {
1038
+ if (typeof window === 'undefined') {
1039
+ return;
1040
+ }
1041
+
1042
+ const handlePopState = () => {
1043
+ const params = new URLSearchParams(window.location.search);
1044
+ const category = params.get('category') || 'semantic';
1045
+ const token = params.get('token') || '';
1046
+
1047
+ // Mark that we're handling browser navigation so pushState doesn't run
1048
+ isNavigatingRef.current = true;
1049
+ setSelectedCategory(category);
1050
+ setSearchValue(token);
1051
+ };
1052
+
1053
+ window.addEventListener('popstate', handlePopState);
1054
+ return () => window.removeEventListener('popstate', handlePopState);
1055
+ }, []);
1056
+
1057
+ const handleSetPage = (_event, newPage) => {
1058
+ setPage(newPage);
1059
+ };
1060
+
1061
+ const handlePerPageSelect = (_event, newPerPage) => {
1062
+ setPerPage(newPerPage);
1063
+ setPage(1);
1064
+ };
1065
+
1066
+ const handleCategoryChange = React.useCallback((category) => {
1067
+ setSelectedCategory(category);
1068
+ }, []);
1069
+
1070
+ // Memoize setters to prevent TokensToolbar from re-rendering unnecessarily
1071
+ const handleSearchChange = React.useCallback((value) => {
1072
+ setSearchValue(value);
1073
+ }, []);
1074
+
1075
+ const handleThemeChange = React.useCallback((theme) => {
1076
+ // Update shared theme (palette is always 'all' and selector is disabled, so this won't be called for palette)
1077
+ setSharedTheme(theme);
1078
+ }, []);
1079
+
1080
+ // Navigate to category and search for specific token
1081
+ const handleNavigateToToken = React.useCallback((category, tokenName) => {
1082
+ setSelectedCategory(category);
1083
+ setSearchValue(tokenName);
1084
+ setPage(1); // Reset to first page
1085
+ }, []);
241
1086
 
242
1087
  return (
243
1088
  <React.Fragment>
244
1089
  <TokensToolbar
245
- searchValue={searchValue}
246
- setSearchValue={setSearchValue}
247
- selectedCategory={selectedCategory}
248
- setSelectedCategory={setSelectedCategory}
249
- resultsCount={searchResults.length.toString()}
250
- categories={allCategoriesArr}
251
- />
252
- <OuterScrollContainer className="tokens-table-outer-wrapper">
1090
+ searchValue={searchValue}
1091
+ setSearchValue={handleSearchChange}
1092
+ selectedCategory={selectedCategory}
1093
+ setSelectedCategory={handleCategoryChange}
1094
+ selectedTheme={selectedTheme}
1095
+ setSelectedTheme={handleThemeChange}
1096
+ themeOptions={themeOptions}
1097
+ resultsCount={resultsCount}
1098
+ categories={allCategoriesArr}
1099
+ />
1100
+ <OuterScrollContainer className="tokens-table-outer-wrapper">
253
1101
  <InnerScrollContainer>
254
- <Title headingLevel="h2">{capitalize(selectedCategory)} tokens</Title>
1102
+ <Title headingLevel="h2">
1103
+ {capitalize(selectedCategory)} tokens {selectedTheme !== 'all' && (
1104
+ <small className="ws-tokens-title-theme"> ({getThemeDisplayName(selectedTheme).trim()})</small>
1105
+ )}
1106
+ </Title>
255
1107
  {searchResults.length > 0 ? (
256
- <Table variant="compact" style={{ marginBlockEnd: `var(--pf-t--global--spacer--xl)` }}>
257
- <Thead>
258
- <Tr>
259
- {/* Only semantic tokens have description, adjust columns accordingly */}
260
- <Th width={5} screenReaderText="Row expansion"></Th>
261
- <Th width={isSemanticLayer ? 60 : 80}>Name</Th>
262
- <Th width={isSemanticLayer ? 10 : 15}>Value</Th>
263
- {isSemanticLayer && <Th width={25}>Description</Th>}
264
- </Tr>
265
- </Thead>
266
-
267
- {/* Loop through row for each token in current layer */}
268
- {searchResults.map((token, rowIndex) => (
269
- <TokensTableBody
270
- key={rowIndex}
271
- token={token}
272
- expandedTokens={expandedTokens}
273
- setExpanded={setExpanded}
274
- isSemanticLayer={isSemanticLayer}
275
- rowIndex={rowIndex}
276
- />
277
- ))}
278
- </Table>
1108
+ <>
1109
+ <OtherCategoryResults
1110
+ otherResults={otherCategoryResults}
1111
+ onCategoryClick={handleCategoryChange}
1112
+ />
1113
+ <Pagination
1114
+ itemCount={totalItems}
1115
+ perPage={perPage}
1116
+ page={page}
1117
+ onSetPage={handleSetPage}
1118
+ onPerPageSelect={handlePerPageSelect}
1119
+ perPageOptions={[
1120
+ { title: '10', value: 10 },
1121
+ { title: '20', value: 20 },
1122
+ { title: '50', value: 50 },
1123
+ { title: '100', value: 100 }
1124
+ ]}
1125
+ variant="top"
1126
+ />
1127
+ <Table
1128
+ variant="compact"
1129
+ className="tokens-table-fixed-layout"
1130
+ style={{ marginBlockEnd: `var(--pf-t--global--spacer--xl)` }}
1131
+ >
1132
+ <Thead>
1133
+ <Tr>
1134
+ <Th width={isSemanticLayer ? 33 : showUsedByColumn ? 30 : isChartLayer ? 40 : 52}>Name</Th>
1135
+ <Th modifier="breakWord" width={isSemanticLayer ? 46 : showUsedByColumn ? 40 : isChartLayer ? 60 : 48}>
1136
+ Value
1137
+ </Th>
1138
+ {isSemanticLayer && <Th width={21}>Description</Th>}
1139
+ {showUsedByColumn && <Th width={30}>Used by</Th>}
1140
+ </Tr>
1141
+ </Thead>
1142
+
1143
+ <Tbody>
1144
+ {paginatedResults.map((token) => (
1145
+ <TokensTableBody
1146
+ key={token[0]}
1147
+ token={token}
1148
+ isSemanticLayer={isSemanticLayer}
1149
+ isChartLayer={isChartLayer}
1150
+ isBaseLayer={isBaseLayer}
1151
+ isPaletteLayer={isPaletteLayer}
1152
+ exhibitsThemeVariantValues={exhibitsThemeVariantValues}
1153
+ selectedTheme={selectedTheme}
1154
+ referenceMap={referenceMap}
1155
+ onNavigate={handleNavigateToToken}
1156
+ mergedTokens={mergedTokens}
1157
+ allAvailableThemes={allAvailableThemes}
1158
+ />
1159
+ ))}
1160
+ </Tbody>
1161
+ </Table>
1162
+ <Pagination
1163
+ itemCount={totalItems}
1164
+ perPage={perPage}
1165
+ page={page}
1166
+ onSetPage={handleSetPage}
1167
+ onPerPageSelect={handlePerPageSelect}
1168
+ perPageOptions={[
1169
+ { title: '10', value: 10 },
1170
+ { title: '20', value: 20 },
1171
+ { title: '50', value: 50 },
1172
+ { title: '100', value: 100 }
1173
+ ]}
1174
+ variant="bottom"
1175
+ />
1176
+ </>
279
1177
  ) : (
280
1178
  <EmptyState titleText="No results found" headingLevel="h4" icon={SearchIcon}>
281
1179
  <EmptyStateBody>No results match the filter criteria. Clear all filters and try again.</EmptyStateBody>