From 830144760f7d7777af6428b79d882d240013cb41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Mon, 8 Dec 2025 19:42:45 +0100 Subject: [PATCH 1/2] refactor(ui-view,ui-themes,emotion): migrate View and ContextView to token-based theme system - Remove theme.ts files from View and ContextView components - Switch from withStyleRework to withStyle decorator - Update components to use NewComponentTypes and SharedTokens - Enhance calcMarginFromShorthand to support hyphen-case tokens - Add elevationTokenToBoxShadow utility for box-shadow token conversion - Add processBorderRadiusValue and processBorderWidthValue helpers - Add withFocusOutline parameter to calcFocusOutlineStyles - Fix ContextView placement padding override with high-specificity selector --- .../migrating-to-new-tokens.md | 2 +- packages/emotion/src/index.ts | 2 +- ....tsx => calcSpacingFromShorthand.test.tsx} | 68 +++--- .../src/styleUtils/calcFocusOutlineStyles.ts | 3 + .../src/styleUtils/calcMarginFromShorthand.ts | 86 -------- .../styleUtils/calcSpacingFromShorthand.ts | 127 +++++++++++ packages/emotion/src/styleUtils/index.ts | 2 +- packages/ui-avatar/src/Avatar/styles.ts | 4 +- .../ui-color-picker/src/ColorPicker/styles.ts | 4 +- .../src/FormFieldLayout/styles.ts | 4 +- packages/ui-link/src/Link/styles.ts | 4 +- packages/ui-spinner/src/Spinner/styles.ts | 4 +- packages/ui-themes/src/index.ts | 4 +- .../src/utils/boxShadowObjectToString.ts | 69 ++++-- packages/ui-view/src/ContextView/index.tsx | 5 +- packages/ui-view/src/ContextView/styles.ts | 11 +- packages/ui-view/src/ContextView/theme.ts | 48 ----- packages/ui-view/src/View/index.tsx | 5 +- packages/ui-view/src/View/styles.ts | 199 +++++++++--------- packages/ui-view/src/View/theme.ts | 117 ---------- 20 files changed, 350 insertions(+), 418 deletions(-) rename packages/emotion/src/styleUtils/__tests__/{calcMarginFromShorthand.test.tsx => calcSpacingFromShorthand.test.tsx} (69%) delete mode 100644 packages/emotion/src/styleUtils/calcMarginFromShorthand.ts create mode 100644 packages/emotion/src/styleUtils/calcSpacingFromShorthand.ts delete mode 100644 packages/ui-view/src/ContextView/theme.ts delete mode 100644 packages/ui-view/src/View/theme.ts diff --git a/docs/contributor-docs/migrating-to-new-tokens.md b/docs/contributor-docs/migrating-to-new-tokens.md index 2bf2a1cd55..5853f869ac 100644 --- a/docs/contributor-docs/migrating-to-new-tokens.md +++ b/docs/contributor-docs/migrating-to-new-tokens.md @@ -22,7 +22,7 @@ Changes needed: If tokens are from a different (usually parent) components, add the `componentID` of that component as second paramater of `@withStyle` and use that name in the `generateStyle` function in `style.ts`: `NewComponentTypes['ParentComponentNameWithTheTokens']` -`generateStyle` accepts a third parameter as well, which are the `sharedTokens`. These provide tokens for shared behaviors such as focus rings, shadows or margins. `'@instructure/emotion'` has various util functions that uses these, such as `calcMarginFromShorthand` and `calcFocusOutlineStyles`. +`generateStyle` accepts a third parameter as well, which are the `sharedTokens`. These provide tokens for shared behaviors such as focus rings, shadows or margins. `'@instructure/emotion'` has various util functions that uses these, such as `calcSpacingFromShorthand` and `calcFocusOutlineStyles`. ## Removing View diff --git a/packages/emotion/src/index.ts b/packages/emotion/src/index.ts index f777d63040..14de7d1b58 100644 --- a/packages/emotion/src/index.ts +++ b/packages/emotion/src/index.ts @@ -35,7 +35,7 @@ export { getShorthandPropValue, mirrorShorthandCorners, mirrorShorthandEdges, - calcMarginFromShorthand, + calcSpacingFromShorthand, calcFocusOutlineStyles } from './styleUtils' diff --git a/packages/emotion/src/styleUtils/__tests__/calcMarginFromShorthand.test.tsx b/packages/emotion/src/styleUtils/__tests__/calcSpacingFromShorthand.test.tsx similarity index 69% rename from packages/emotion/src/styleUtils/__tests__/calcMarginFromShorthand.test.tsx rename to packages/emotion/src/styleUtils/__tests__/calcSpacingFromShorthand.test.tsx index ae0f71823c..cf4e535125 100644 --- a/packages/emotion/src/styleUtils/__tests__/calcMarginFromShorthand.test.tsx +++ b/packages/emotion/src/styleUtils/__tests__/calcSpacingFromShorthand.test.tsx @@ -23,9 +23,9 @@ */ import { describe, it, expect, vi } from 'vitest' -import { calcMarginFromShorthand } from '../calcMarginFromShorthand' +import { calcSpacingFromShorthand } from '../calcSpacingFromShorthand' -describe('calcMarginFromShorthand', () => { +describe('calcSpacingFromShorthand', () => { const spacingMap = { space0: '0px', space4: '4px', @@ -47,65 +47,65 @@ describe('calcMarginFromShorthand', () => { describe('single token values', () => { it('should resolve a direct key to its value', () => { - expect(calcMarginFromShorthand('space4', spacingMap)).toBe('4px') + expect(calcSpacingFromShorthand('space4', spacingMap)).toBe('4px') }) it('should resolve space0 to 0px', () => { - expect(calcMarginFromShorthand('space0', spacingMap)).toBe('0px') + expect(calcSpacingFromShorthand('space0', spacingMap)).toBe('0px') }) it('should resolve space16 to 16px', () => { - expect(calcMarginFromShorthand('space16', spacingMap)).toBe('16px') + expect(calcSpacingFromShorthand('space16', spacingMap)).toBe('16px') }) }) describe('multiple token values (CSS shorthand)', () => { it('should handle two token values', () => { - expect(calcMarginFromShorthand('space4 space8', spacingMap)).toBe('4px 8px') + expect(calcSpacingFromShorthand('space4 space8', spacingMap)).toBe('4px 8px') }) it('should handle three token values', () => { - expect(calcMarginFromShorthand('space4 gap.sm space16', spacingMap)).toBe('4px 2px 16px') + expect(calcSpacingFromShorthand('space4 gap.sm space16', spacingMap)).toBe('4px 2px 16px') }) it('should handle four token values', () => { - expect(calcMarginFromShorthand('space0 space4 space8 space16', spacingMap)).toBe('0px 4px 8px 16px') + expect(calcSpacingFromShorthand('space0 space4 space8 space16', spacingMap)).toBe('0px 4px 8px 16px') }) }) describe('nested token paths with dot notation', () => { it('should resolve single-level nested path', () => { - expect(calcMarginFromShorthand('gap.sm', spacingMap)).toBe('2px') + expect(calcSpacingFromShorthand('gap.sm', spacingMap)).toBe('2px') }) it('should resolve two-level nested path', () => { - expect(calcMarginFromShorthand('gap.nested.xl', spacingMap)).toBe('24px') + expect(calcSpacingFromShorthand('gap.nested.xl', spacingMap)).toBe('24px') }) it('should handle multiple nested paths', () => { - expect(calcMarginFromShorthand('gap.sm gap.nested.xl', spacingMap)).toBe('2px 24px') + expect(calcSpacingFromShorthand('gap.sm gap.nested.xl', spacingMap)).toBe('2px 24px') }) it('should handle padding.small', () => { - expect(calcMarginFromShorthand('padding.small', spacingMap)).toBe('4px') + expect(calcSpacingFromShorthand('padding.small', spacingMap)).toBe('4px') }) it('should handle padding.large', () => { - expect(calcMarginFromShorthand('padding.large', spacingMap)).toBe('16px') + expect(calcSpacingFromShorthand('padding.large', spacingMap)).toBe('16px') }) }) describe('mixing direct keys and nested paths', () => { it('should handle space0 and gap.sm', () => { - expect(calcMarginFromShorthand('space0 gap.sm', spacingMap)).toBe('0px 2px') + expect(calcSpacingFromShorthand('space0 gap.sm', spacingMap)).toBe('0px 2px') }) it('should handle padding.small and gap.md', () => { - expect(calcMarginFromShorthand('padding.small gap.md', spacingMap)).toBe('4px 8px') + expect(calcSpacingFromShorthand('padding.small gap.md', spacingMap)).toBe('4px 8px') }) it('should handle four mixed values', () => { - expect(calcMarginFromShorthand('space4 gap.sm padding.large space16', spacingMap)).toBe('4px 2px 16px 16px') + expect(calcSpacingFromShorthand('space4 gap.sm padding.large space16', spacingMap)).toBe('4px 2px 16px 16px') }) }) @@ -113,7 +113,7 @@ describe('calcMarginFromShorthand', () => { it('should return the original token when not found in spacingMap', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = calcMarginFromShorthand('nonExistent', spacingMap) + const result = calcSpacingFromShorthand('nonExistent', spacingMap) expect(result).toBe('nonExistent') expect(consoleWarnSpy).toHaveBeenCalledWith('Theme token path "nonExistent" not found in theme.') @@ -123,7 +123,7 @@ describe('calcMarginFromShorthand', () => { it('should handle CSS values like auto', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = calcMarginFromShorthand('auto', spacingMap) + const result = calcSpacingFromShorthand('auto', spacingMap) expect(result).toBe('auto') consoleWarnSpy.mockRestore() @@ -132,7 +132,7 @@ describe('calcMarginFromShorthand', () => { it('should handle direct CSS values like 10px', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = calcMarginFromShorthand('10px', spacingMap) + const result = calcSpacingFromShorthand('10px', spacingMap) expect(result).toBe('10px') consoleWarnSpy.mockRestore() @@ -141,7 +141,7 @@ describe('calcMarginFromShorthand', () => { it('should handle mixed valid tokens and CSS values', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = calcMarginFromShorthand('auto 10px', spacingMap) + const result = calcSpacingFromShorthand('auto 10px', spacingMap) expect(result).toBe('auto 10px') consoleWarnSpy.mockRestore() @@ -150,7 +150,7 @@ describe('calcMarginFromShorthand', () => { it('should handle mixed valid tokens and invalid nested paths', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = calcMarginFromShorthand('space4 gap.invalid', spacingMap) + const result = calcSpacingFromShorthand('space4 gap.invalid', spacingMap) expect(result).toBe('4px gap.invalid') expect(consoleWarnSpy).toHaveBeenCalledWith('Theme token path "gap.invalid" not found in theme.') @@ -160,7 +160,7 @@ describe('calcMarginFromShorthand', () => { it('should handle deeply nested invalid path', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = calcMarginFromShorthand('gap.nested.xl.invalid', spacingMap) + const result = calcSpacingFromShorthand('gap.nested.xl.invalid', spacingMap) expect(result).toBe('gap.nested.xl.invalid') expect(consoleWarnSpy).toHaveBeenCalledWith('Theme token path "gap.nested.xl.invalid" not found in theme.') @@ -169,24 +169,24 @@ describe('calcMarginFromShorthand', () => { }) describe('undefined and edge cases', () => { - it('should return "0" for undefined value', () => { - expect(calcMarginFromShorthand(undefined, spacingMap)).toBe('0') + it('should return undefined for undefined value', () => { + expect(calcSpacingFromShorthand(undefined, spacingMap)).toBe(undefined) }) it('should handle empty string', () => { - expect(calcMarginFromShorthand('', spacingMap)).toBe('') + expect(calcSpacingFromShorthand('', spacingMap)).toBe(undefined) }) it('should handle string with only whitespace', () => { - expect(calcMarginFromShorthand(' ', spacingMap)).toBe('') + expect(calcSpacingFromShorthand(' ', spacingMap)).toBe('') }) it('should handle extra spaces between tokens', () => { - expect(calcMarginFromShorthand('space4 space8', spacingMap)).toBe('4px 8px') + expect(calcSpacingFromShorthand('space4 space8', spacingMap)).toBe('4px 8px') }) it('should trim leading and trailing whitespace', () => { - expect(calcMarginFromShorthand(' space4 space8 ', spacingMap)).toBe('4px 8px') + expect(calcSpacingFromShorthand(' space4 space8 ', spacingMap)).toBe('4px 8px') }) }) @@ -194,7 +194,7 @@ describe('calcMarginFromShorthand', () => { it('should warn when a direct key is not found', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - calcMarginFromShorthand('invalidToken', spacingMap) + calcSpacingFromShorthand('invalidToken', spacingMap) expect(consoleWarnSpy).toHaveBeenCalledWith('Theme token path "invalidToken" not found in theme.') consoleWarnSpy.mockRestore() @@ -203,7 +203,7 @@ describe('calcMarginFromShorthand', () => { it('should warn when a nested path is not found', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - calcMarginFromShorthand('gap.invalid', spacingMap) + calcSpacingFromShorthand('gap.invalid', spacingMap) expect(consoleWarnSpy).toHaveBeenCalledWith('Theme token path "gap.invalid" not found in theme.') consoleWarnSpy.mockRestore() @@ -212,7 +212,7 @@ describe('calcMarginFromShorthand', () => { it('should warn for each invalid token in a multi-token value', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - calcMarginFromShorthand('invalid1 invalid2', spacingMap) + calcSpacingFromShorthand('invalid1 invalid2', spacingMap) expect(consoleWarnSpy).toHaveBeenCalledTimes(2) expect(consoleWarnSpy).toHaveBeenNthCalledWith(1, 'Theme token path "invalid1" not found in theme.') expect(consoleWarnSpy).toHaveBeenNthCalledWith(2, 'Theme token path "invalid2" not found in theme.') @@ -223,17 +223,17 @@ describe('calcMarginFromShorthand', () => { describe('complex scenarios', () => { it('should handle all direct keys', () => { - expect(calcMarginFromShorthand('space0 space4 space8 space16', spacingMap)).toBe('0px 4px 8px 16px') + expect(calcSpacingFromShorthand('space0 space4 space8 space16', spacingMap)).toBe('0px 4px 8px 16px') }) it('should handle all nested paths', () => { - expect(calcMarginFromShorthand('gap.sm gap.md gap.lg gap.nested.xl', spacingMap)).toBe('2px 8px 16px 24px') + expect(calcSpacingFromShorthand('gap.sm gap.md gap.lg gap.nested.xl', spacingMap)).toBe('2px 8px 16px 24px') }) it('should handle mix of everything', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const result = calcMarginFromShorthand('space4 gap.md auto padding.small', spacingMap) + const result = calcSpacingFromShorthand('space4 gap.md auto padding.small', spacingMap) expect(result).toBe('4px 8px auto 4px') consoleWarnSpy.mockRestore() diff --git a/packages/emotion/src/styleUtils/calcFocusOutlineStyles.ts b/packages/emotion/src/styleUtils/calcFocusOutlineStyles.ts index 234b3bd0bc..1377aadc19 100644 --- a/packages/emotion/src/styleUtils/calcFocusOutlineStyles.ts +++ b/packages/emotion/src/styleUtils/calcFocusOutlineStyles.ts @@ -53,12 +53,14 @@ const calcFocusOutlineStyles = ( focusPosition?: 'offset' | 'inset' shouldAnimateFocus?: boolean focusWithin?: boolean + withFocusOutline?: boolean } ) => { const focusColor = params?.focusColor ?? 'info' const focusPosition = params?.focusPosition ?? 'offset' const shouldAnimateFocus = params?.shouldAnimateFocus ?? true const focusWithin = params?.focusWithin ?? false + const withFocusOutline = params?.withFocusOutline ?? false const focusColorVariants = { info: theme.infoColor, @@ -80,6 +82,7 @@ const calcFocusOutlineStyles = ( outlineOffset: '-0.8rem', outlineStyle: 'solid', outlineColor: alpha(outlineStyle.outlineColor, 0), + ...(withFocusOutline && outlineStyle), '&:focus': { ...outlineStyle, '&:hover, &:active': { diff --git a/packages/emotion/src/styleUtils/calcMarginFromShorthand.ts b/packages/emotion/src/styleUtils/calcMarginFromShorthand.ts deleted file mode 100644 index d1243b7bd1..0000000000 --- a/packages/emotion/src/styleUtils/calcMarginFromShorthand.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015 - present Instructure, Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -import type { Spacing } from './ThemeablePropValues' - -type DeepStringRecord = { - [key: string]: string | DeepStringRecord -} - -/** - * Converts shorthand margin values into CSS margin strings using theme spacing tokens. - * - * This function parses space-separated margin values and resolves theme tokens to their - * actual CSS values. It supports CSS shorthand syntax (1-4 values) and nested theme - * token paths using dot notation. - * - * @param {Spacing | undefined} value - The shorthand margin value string containing space-separated tokens or CSS values. - * Can be undefined, in which case '0' is returned. - * @param {Record} spacingMap - The spacing theme object containing margin tokens and nested values. - * Typically comes from `sharedTokens.margin.spacing` in the component theme. - * - * @returns {string} The resolved CSS margin string ready to be used in styles. - */ -export function calcMarginFromShorthand( - value: Spacing | undefined, - spacingMap: DeepStringRecord -) { - if (value === undefined) { - return '0' - } - const tokens = value.trim().split(' ') - - // Map each token to its resolved CSS value - const resolvedValues = tokens.map(token => { - // If the token is already a direct key in spacingMap, and it's a string, return its value - const directValue = spacingMap[token] - if (typeof directValue === 'string') { - return directValue - } - - // Handle dot notation for nested theme token paths - if (token.includes('.')) { - const path = token.split('.') - let currentLevel: string | DeepStringRecord = spacingMap - - for (const key of path) { - if (currentLevel && typeof currentLevel === 'object' && key in currentLevel) { - currentLevel = currentLevel[key] - } else { - console.warn(`Theme token path "${token}" not found in theme.`) - // If path doesn't resolve, return the original token as fallback - return token - } - } - if (typeof currentLevel === 'string') { - return currentLevel - } - } - // Return the original token if not found (could be a direct CSS value like 'auto', '10px', etc.) - console.warn(`Theme token path "${token}" not found in theme.`) - return token - }) - - // Return the space-separated resolved values - return resolvedValues.join(' ') -} diff --git a/packages/emotion/src/styleUtils/calcSpacingFromShorthand.ts b/packages/emotion/src/styleUtils/calcSpacingFromShorthand.ts new file mode 100644 index 0000000000..81cb3dfe9a --- /dev/null +++ b/packages/emotion/src/styleUtils/calcSpacingFromShorthand.ts @@ -0,0 +1,127 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import type { Spacing } from './ThemeablePropValues' + +type DeepStringRecord = { + [key: string]: string | DeepStringRecord +} + +/** + * Converts hyphen-case strings to camelCase + * Example: 'medium-small' -> 'mediumSmall' + */ +function camelize(str: string): string { + return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()) +} + +/** + * Converts shorthand spacing values into CSS strings using theme spacing tokens. + * + * This function parses space-separated spacing values (margin, padding) and resolves theme + * tokens to their actual CSS values. It supports CSS shorthand syntax (1-4 values), nested + * theme token paths using dot notation, and automatically converts hyphen-case tokens to camelCase. + * + * @param {Spacing | undefined} value - The shorthand spacing value string containing space-separated tokens or CSS values. + * Tokens can be in camelCase (mediumSmall) or hyphen-case (medium-small). + * Can be undefined, in which case '0' is returned. + * @param {Record} spacingMap - The spacing theme object containing spacing tokens and nested values. + * Typically comes from `sharedTokens.margin.spacing` or `sharedTokens.padding.spacing` in the component theme. + * + * @returns {string} The resolved CSS spacing string ready to be used in styles. + * + * @example + * // Hyphen-case tokens are converted to camelCase + * calcSpacingFromShorthand('medium-small', spacingMap) // resolves to spacingMap.mediumSmall + * calcSpacingFromShorthand('x-large small', spacingMap) // resolves to spacingMap.xLarge + spacingMap.small + * + * // Dot notation paths are NOT converted + * calcSpacingFromShorthand('gap.nested-value', spacingMap) // resolves to spacingMap.gap['nested-value'] + * + * // CSS values like 'none', 'auto', '10px' are returned as-is + * calcSpacingFromShorthand('none', spacingMap) // returns 'none' + */ +export function calcSpacingFromShorthand( + value: Spacing | undefined, + spacingMap: DeepStringRecord +) { + // return undefined when there is no value -> this is important when a component (like View) + // doesn't have a prop like `padding` but has inline css for padding + // this makes sure to not overwrite the inline style + if (!value) return + + const tokens = value.trim().split(' ') + + // Handle whitespace-only strings + if (tokens.length === 1 && tokens[0] === '') return '' + + // Map each token to its resolved CSS value + const resolvedValues = tokens.map((token) => { + // Handle special CSS value 'none' - convert to 0 for valid CSS + if (token === 'none') { + return '0' + } + + // Handle valid CSS numeric and keyword values + if (token === '0' || token === 'auto') { + return token + } + + // Handle dot notation for nested theme token paths (no camelization) + if (token.includes('.')) { + const path = token.split('.') + let currentLevel: string | DeepStringRecord = spacingMap + + for (const key of path) { + if ( + currentLevel && + typeof currentLevel === 'object' && + key in currentLevel + ) { + currentLevel = currentLevel[key] + } else { + console.warn(`Theme token path "${token}" not found in theme.`) + // If path doesn't resolve, return the original token as fallback + return token + } + } + if (typeof currentLevel === 'string') { + return currentLevel + } + } + + // For direct tokens, try camelized version + const camelizedToken = camelize(token) + const directValue = spacingMap[camelizedToken] + if (typeof directValue === 'string') { + return directValue + } + + // Return the original token if not found (could be a direct CSS value like 'auto', '10px', etc.) + console.warn(`Theme token path "${token}" not found in theme.`) + return token + }) + + // Return the space-separated resolved values + return resolvedValues.join(' ') +} diff --git a/packages/emotion/src/styleUtils/index.ts b/packages/emotion/src/styleUtils/index.ts index a16e047fe0..ac3318ed9e 100644 --- a/packages/emotion/src/styleUtils/index.ts +++ b/packages/emotion/src/styleUtils/index.ts @@ -27,7 +27,7 @@ export { makeThemeVars } from './makeThemeVars' export { getShorthandPropValue } from './getShorthandPropValue' export { mirrorShorthandCorners } from './mirrorShorthandCorners' export { mirrorShorthandEdges } from './mirrorShorthandEdges' -export { calcMarginFromShorthand } from './calcMarginFromShorthand' +export { calcSpacingFromShorthand } from './calcSpacingFromShorthand' export { calcFocusOutlineStyles } from './calcFocusOutlineStyles' export type { diff --git a/packages/ui-avatar/src/Avatar/styles.ts b/packages/ui-avatar/src/Avatar/styles.ts index 4edc75dd65..a19c1f4c46 100644 --- a/packages/ui-avatar/src/Avatar/styles.ts +++ b/packages/ui-avatar/src/Avatar/styles.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { calcMarginFromShorthand } from '@instructure/emotion' +import { calcSpacingFromShorthand } from '@instructure/emotion' import type { NewComponentTypes, SharedTokens } from '@instructure/ui-themes' import { AvatarProps, AvatarStyle } from './props' @@ -183,7 +183,7 @@ const generateStyle = ( fontWeight: componentTheme.fontWeight, overflow: 'hidden', // TODO handle the merging on tokens inside the util - margin: calcMarginFromShorthand(margin, { + margin: calcSpacingFromShorthand(margin, { ...sharedTokens.spacing, ...sharedTokens.legacySpacing }) diff --git a/packages/ui-color-picker/src/ColorPicker/styles.ts b/packages/ui-color-picker/src/ColorPicker/styles.ts index e52df302b5..e1ec1bac5d 100644 --- a/packages/ui-color-picker/src/ColorPicker/styles.ts +++ b/packages/ui-color-picker/src/ColorPicker/styles.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { calcMarginFromShorthand } from '@instructure/emotion' +import { calcSpacingFromShorthand } from '@instructure/emotion' import type { ColorPickerTheme } from '@instructure/shared-types' import type { @@ -56,7 +56,7 @@ const generateStyle = ( const { checkContrast, popoverMaxHeight, margin } = props const { isSimple, calculatedPopoverMaxHeight } = state - const cssMargin = calcMarginFromShorthand(margin, spacing) + const cssMargin = calcSpacingFromShorthand(margin, spacing) return { colorPicker: { label: 'colorPicker', diff --git a/packages/ui-form-field/src/FormFieldLayout/styles.ts b/packages/ui-form-field/src/FormFieldLayout/styles.ts index 5f59af8245..98fd755395 100644 --- a/packages/ui-form-field/src/FormFieldLayout/styles.ts +++ b/packages/ui-form-field/src/FormFieldLayout/styles.ts @@ -28,7 +28,7 @@ import type { FormFieldStyleProps } from './props' import type { NewComponentTypes, SharedTokens } from '@instructure/ui-themes' -import { calcMarginFromShorthand } from '@instructure/emotion' +import { calcSpacingFromShorthand } from '@instructure/emotion' type StyleParams = FormFieldStyleProps & { inline: FormFieldLayoutProps['inline'] @@ -88,7 +88,7 @@ const generateStyle = ( ): FormFieldLayoutStyle => { const { inline, layout, vAlign, labelAlign, margin, messages } = params const { hasMessages, hasVisibleLabel, hasErrorMsgAndIsGroup } = params - const cssMargin = calcMarginFromShorthand(margin, sharedTokens.spacing) + const cssMargin = calcSpacingFromShorthand(margin, {...sharedTokens.spacing, ...sharedTokens.legacySpacing}) const isInlineLayout = layout === 'inline' const hasNonEmptyMessages = messages?.reduce( diff --git a/packages/ui-link/src/Link/styles.ts b/packages/ui-link/src/Link/styles.ts index e412ec1d4c..1bcd3b547c 100644 --- a/packages/ui-link/src/Link/styles.ts +++ b/packages/ui-link/src/Link/styles.ts @@ -26,7 +26,7 @@ import { NewComponentTypes, SharedTokens } from '@instructure/ui-themes' import type { LinkProps, LinkStyle, LinkStyleProps } from './props' import { calcFocusOutlineStyles, - calcMarginFromShorthand + calcSpacingFromShorthand, } from '@instructure/emotion' /** * --- @@ -236,7 +236,7 @@ const generateStyle = ( label: 'link', ...baseStyles, // TODO handle the merging on tokens inside the util - margin: calcMarginFromShorthand(margin, { + margin: calcSpacingFromShorthand(margin, { ...sharedTokens.spacing, ...sharedTokens.legacySpacing }), diff --git a/packages/ui-spinner/src/Spinner/styles.ts b/packages/ui-spinner/src/Spinner/styles.ts index 20544cf1a3..45bc568705 100644 --- a/packages/ui-spinner/src/Spinner/styles.ts +++ b/packages/ui-spinner/src/Spinner/styles.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { keyframes, calcMarginFromShorthand } from '@instructure/emotion' +import { keyframes, calcSpacingFromShorthand } from '@instructure/emotion' import type { NewComponentTypes, SharedTokens } from '@instructure/ui-themes' import type { SpinnerProps, SpinnerStyle } from './props' @@ -167,7 +167,7 @@ const generateStyle = ( verticalAlign: 'middle', ...spinnerSizes[size!], // TODO handle the merging on tokens inside the util - margin: calcMarginFromShorthand(margin, { + margin: calcSpacingFromShorthand(margin, { ...sharedTokens.spacing, ...sharedTokens.legacySpacing }) diff --git a/packages/ui-themes/src/index.ts b/packages/ui-themes/src/index.ts index 024c8b096b..476fb125ea 100644 --- a/packages/ui-themes/src/index.ts +++ b/packages/ui-themes/src/index.ts @@ -50,6 +50,7 @@ import { additionalPrimitives } from './sharedThemeTokens/colors/primitives' import dataVisualization from './sharedThemeTokens/colors/dataVisualization' +import { boxShadowObjectsToCSSString } from './utils/boxShadowObjectToString' import type { Canvas as NewCanvas, @@ -85,7 +86,8 @@ export { canvasHighContrast, primitives, additionalPrimitives, - dataVisualization + dataVisualization, + boxShadowObjectsToCSSString, } export default canvas export type { diff --git a/packages/ui-themes/src/utils/boxShadowObjectToString.ts b/packages/ui-themes/src/utils/boxShadowObjectToString.ts index d98763427d..e18f25812c 100644 --- a/packages/ui-themes/src/utils/boxShadowObjectToString.ts +++ b/packages/ui-themes/src/utils/boxShadowObjectToString.ts @@ -27,20 +27,63 @@ import { TokenBoxshadowValueInst } from '../themes/newThemes/commonTypes' /** * Converts a BoxShadowObject from Token Studio to a CSS box-shadow string */ -function boxShadowObjectToString(boxShadowObject: TokenBoxshadowValueInst) { +function boxShadowToCSSString(boxShadowObject: TokenBoxshadowValueInst) { + // weird string concatenation is to make it look nice in the debugger if (boxShadowObject.type === 'innerShadow') { - return `inset ${boxShadowObject.x} - ${boxShadowObject.y} - ${boxShadowObject.blur ? boxShadowObject.blur : ''} - ${boxShadowObject.spread ? boxShadowObject.spread : ''} - ${boxShadowObject.color}` + return ( + `inset ${boxShadowObject.x} ` + + `${boxShadowObject.y} ` + + `${boxShadowObject.blur ? boxShadowObject.blur : ''} ` + + `${boxShadowObject.spread ? boxShadowObject.spread : ''} ` + + `${boxShadowObject.color}` + ) } - return `${boxShadowObject.x} - ${boxShadowObject.y} - ${boxShadowObject.blur ? boxShadowObject.blur : ''} - ${boxShadowObject.spread ? boxShadowObject.spread : ''} - ${boxShadowObject.color}` + return ( + `${boxShadowObject.x} ` + + `${boxShadowObject.y} ` + + `${boxShadowObject.blur ? boxShadowObject.blur : ''} ` + + `${boxShadowObject.spread ? boxShadowObject.spread : ''} ` + + `${boxShadowObject.color}` + ) } -export default boxShadowObjectToString -export { boxShadowObjectToString } +function getShadowsInOrder( + shadowsObj: Record +) { + return Object.keys(shadowsObj) + .sort((a, b) => { + const numA = parseInt(a) + const numB = parseInt(b) + return numA - numB + }) + .map((key) => shadowsObj[key]) +} + +/** + * Converts a box shadow object that looks like this: + * ``` + * { + * '1': {color: 'rgba(12, 0, 0, 0.2)', x:...}, + * '2': {color: 'rgba(0, 0, 0, 0.1)', x:...}, + * '0': {color: 'rgba(0, 0, 0, 0.1)', x:...} + * } + * ``` + * to a CSS box-shadow string e.g. + * ``` + * 0px 0.375rem 0.4375rem 0px rgba(12,0,0,0.2), + * 0px 0.625rem 1.75rem 0px rgba(0,0,0,0.1), + * 0px 0.625rem 1.75rem 0px rgba(0,0,0,0.1) + * ``` + */ +function boxShadowObjectsToCSSString( + shadowObject: Record +) { + const shadows = getShadowsInOrder(shadowObject) + let result = '' + for (const shadow of shadows) { + result += boxShadowToCSSString(shadow) + ',\n' + } + return result.slice(0, -2) +} + +export { boxShadowToCSSString, boxShadowObjectsToCSSString } diff --git a/packages/ui-view/src/ContextView/index.tsx b/packages/ui-view/src/ContextView/index.tsx index 5f5d2c4d45..88b83b3d6a 100644 --- a/packages/ui-view/src/ContextView/index.tsx +++ b/packages/ui-view/src/ContextView/index.tsx @@ -24,13 +24,12 @@ import { Component } from 'react' -import { withStyleRework as withStyle } from '@instructure/emotion' +import { withStyle } from '@instructure/emotion' import { omitProps } from '@instructure/ui-react-utils' import { View } from '../View' import generateStyle from './styles' -import generateComponentTheme from './theme' import { allowedProps } from './props' import type { ContextViewProps } from './props' @@ -40,7 +39,7 @@ category: components --- **/ -@withStyle(generateStyle, generateComponentTheme) +@withStyle(generateStyle) class ContextView extends Component { static readonly componentId = 'ContextView' static allowedProps = allowedProps diff --git a/packages/ui-view/src/ContextView/styles.ts b/packages/ui-view/src/ContextView/styles.ts index a1c98c3719..279d3be297 100644 --- a/packages/ui-view/src/ContextView/styles.ts +++ b/packages/ui-view/src/ContextView/styles.ts @@ -24,8 +24,8 @@ import { mirrorPlacement } from '@instructure/ui-position' +import type { NewComponentTypes } from '@instructure/ui-themes' import type { PlacementPropValues } from '@instructure/ui-position' -import type { ContextViewTheme } from '@instructure/shared-types' import type { ContextViewProps, ContextViewStyle } from './props' type PlacementArray = PlacementPropValues[] @@ -63,7 +63,7 @@ const topPlacements: PlacementArray = [ const getPlacementStyle = ( placement: PlacementPropValues, - theme: ContextViewTheme + theme: NewComponentTypes['ContextView'] ) => { if (endPlacements.includes(placement)) { return { paddingInlineStart: theme?.arrowSize, paddingInlineEnd: '0' } @@ -83,7 +83,7 @@ const getPlacementStyle = ( const getArrowCorrections = ( placement: PlacementPropValues, - theme: ContextViewTheme + theme: NewComponentTypes['ContextView'] ) => { const center: PlacementArray = [ 'top', @@ -132,7 +132,7 @@ const getArrowCorrections = ( const getArrowPlacementVariant = ( placement: PlacementPropValues, background: ContextViewProps['background'], - theme: ContextViewTheme, + theme: NewComponentTypes['ContextView'], props: ContextViewProps ) => { const transformedPlacement = mirrorPlacement(placement, ' ') @@ -266,11 +266,10 @@ const getArrowPlacementVariant = ( * Generates the style object from the theme and provided additional information * @param {Object} componentTheme The theme variable object. * @param {Object} props the props of the component, the style is applied to - * @param {Object} state the state of the component, the style is applied to * @return {Object} The final style object, which will be used in the component */ const generateStyle = ( - componentTheme: ContextViewTheme, + componentTheme: NewComponentTypes['ContextView'], props: ContextViewProps ): ContextViewStyle => { const { placement, background, borderColor } = props diff --git a/packages/ui-view/src/ContextView/theme.ts b/packages/ui-view/src/ContextView/theme.ts deleted file mode 100644 index 43b4685707..0000000000 --- a/packages/ui-view/src/ContextView/theme.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015 - present Instructure, Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import type { Theme } from '@instructure/ui-themes' -import { ContextViewTheme } from '@instructure/shared-types' - -/** - * Generates the theme object for the component from the theme and provided additional information - * @param {Object} theme The actual theme object. - * @return {Object} The final theme object with the overrides and component variables - */ -const generateComponentTheme = (theme: Theme): ContextViewTheme => { - const componentVariables: ContextViewTheme = { - arrowSize: '0.5rem', - arrowBorderWidth: theme?.borders?.widthSmall, - arrowBackgroundColor: theme?.colors?.contrasts?.white1010, - arrowBorderColor: theme?.colors?.contrasts?.grey1424, - arrowBackgroundColorInverse: theme?.colors?.contrasts?.grey125125, - arrowBorderColorInverse: 'transparent' - } - - return { - ...componentVariables - } -} - -export default generateComponentTheme diff --git a/packages/ui-view/src/View/index.tsx b/packages/ui-view/src/View/index.tsx index 700ef21f6f..c46a3bbae3 100644 --- a/packages/ui-view/src/View/index.tsx +++ b/packages/ui-view/src/View/index.tsx @@ -33,10 +33,9 @@ import { pickProps, passthroughProps } from '@instructure/ui-react-utils' -import { withStyleRework as withStyle } from '@instructure/emotion' +import { withStyle } from '@instructure/emotion' import generateStyle from './styles' -import generateComponentTheme from './theme' import { allowedProps } from './props' import type { ViewProps } from './props' @@ -48,7 +47,7 @@ category: components @module View **/ @textDirectionContextConsumer() -@withStyle(generateStyle, generateComponentTheme) +@withStyle(generateStyle) class View extends Component { static componentId = 'View' static allowedProps = allowedProps diff --git a/packages/ui-view/src/View/styles.ts b/packages/ui-view/src/View/styles.ts index 9aa3208348..fd3f17718e 100644 --- a/packages/ui-view/src/View/styles.ts +++ b/packages/ui-view/src/View/styles.ts @@ -24,41 +24,92 @@ import { DIRECTION } from '@instructure/ui-i18n' import { - getShorthandPropValue, + calcSpacingFromShorthand, mirrorShorthandEdges, - mirrorShorthandCorners + mirrorShorthandCorners, + calcFocusOutlineStyles } from '@instructure/emotion' import { pickProps } from '@instructure/ui-react-utils' -import type { OtherHTMLAttributes, ViewTheme } from '@instructure/shared-types' +import type { OtherHTMLAttributes } from '@instructure/shared-types' +import type { NewComponentTypes, SharedTokens } from '@instructure/ui-themes' import type { ViewProps, ViewStyle, BorderColor } from './props' -import { alpha } from '@instructure/ui-color-utils' +import { boxShadowObjectsToCSSString } from '@instructure/ui-themes' + +const processBorderRadiusValue = ( + value: string | undefined, + sharedTokens: SharedTokens +): string => { + if (!value) return '' + + // Split by spaces to handle CSS shorthand (1-4 values) + const values = value.split(' ') + + const processedValues = values.map((v) => { + // Handle special cases + if (v === 'auto' || v === '0') return v + if (v === 'none') return '0' + if (v === 'circle') return '100%' + if (v === 'pill') return '999em' + + // Handle SharedTokens values + if (v === 'small') return sharedTokens.radiusSmall + if (v === 'medium') return sharedTokens.radiusMedium + if (v === 'large') return sharedTokens.radiusLarge + + // Pass through CSS values (1rem, 12px, etc.) + return v + }) + + return processedValues.join(' ') +} + +const processBorderWidthValue = ( + value: string | undefined, + sharedTokens: SharedTokens +): string => { + if (!value) return '' + + // Split by spaces to handle CSS shorthand (1-4 values) + const values = value.split(' ') + + const processedValues = values.map((v) => { + // Handle special cases + if (v === 'auto' || v === '0') return v + if (v === 'none') return '0' + + // Handle SharedTokens values + if (v === 'small') return sharedTokens.widthSmall + if (v === 'medium') return sharedTokens.widthMedium + if (v === 'large') return sharedTokens.widthLarge + + // Pass through CSS values (1rem, 2px, etc.) + return v + }) + + return processedValues.join(' ') +} const getBorderStyle = ({ borderRadius, borderWidth, dir, - theme + sharedTokens }: { - theme: ViewTheme + sharedTokens: SharedTokens borderRadius: ViewProps['borderRadius'] borderWidth: ViewProps['borderWidth'] dir: ViewProps['dir'] }) => { const isRtlDirection = dir === DIRECTION.rtl return { - borderRadius: getShorthandPropValue( - 'View', - theme, + borderRadius: processBorderRadiusValue( isRtlDirection ? mirrorShorthandCorners(borderRadius) : borderRadius, - 'borderRadius', - true + sharedTokens ), - borderWidth: getShorthandPropValue( - 'View', - theme, + borderWidth: processBorderWidthValue( isRtlDirection ? mirrorShorthandEdges(borderWidth) : borderWidth, - 'borderWidth' + sharedTokens ) } } @@ -67,9 +118,9 @@ const getSpacingStyle = ({ margin, padding, dir, - theme + sharedTokens }: { - theme: ViewTheme + sharedTokens: SharedTokens margin: ViewProps['margin'] padding: ViewProps['padding'] dir: ViewProps['dir'] @@ -77,17 +128,20 @@ const getSpacingStyle = ({ const isRtlDirection = dir === DIRECTION.rtl return { - margin: getShorthandPropValue( - 'View', - theme, + // TODO handle the merging on tokens inside the util + margin: calcSpacingFromShorthand( isRtlDirection ? mirrorShorthandEdges(margin) : margin, - 'margin' + { + ...sharedTokens.spacing, + ...sharedTokens.legacySpacing + } ), - padding: getShorthandPropValue( - 'View', - theme, + padding: calcSpacingFromShorthand( isRtlDirection ? mirrorShorthandEdges(padding) : padding, - 'padding' + { + ...sharedTokens.spacing, + ...sharedTokens.legacySpacing + } ) } } @@ -165,63 +219,6 @@ const withBorder = (props: ViewProps) => { return borderWidth && borderWidth !== '0' && borderWidth !== 'none' } -const getFocusStyles = (props: ViewProps, componentTheme: ViewTheme) => { - const { - focusColor, - focusPosition, - shouldAnimateFocus, - withFocusOutline, - focusWithin - } = props - - const focusPos = - focusPosition == 'offset' || focusPosition == 'inset' - ? focusPosition - : 'offset' - const focusPositionVariants = { - offset: { - outlineOffset: `calc(${componentTheme.focusOutlineOffset} - ${componentTheme.focusOutlineWidth})` - }, - inset: { - outlineOffset: `calc(${componentTheme.focusOutlineInset} - ${componentTheme.focusOutlineWidth})` - } - } - const focusColorVariants = { - info: componentTheme.focusColorInfo, - inverse: componentTheme.focusColorInverse, - success: componentTheme.focusColorSuccess, - danger: componentTheme.focusColorDanger - } - const visibleFocusStyle = { - ...focusPositionVariants[focusPos], - outlineColor: focusColorVariants[focusColor!] - } - const outlineStyle = { - outlineOffset: '-0.8rem', // value when not in focus, its invisible - outlineColor: alpha(focusColorVariants[focusColor!], 0), - outlineStyle: componentTheme.focusOutlineStyle, - outlineWidth: componentTheme.focusOutlineWidth, - ...(withFocusOutline && visibleFocusStyle) - } - return { - ...(shouldAnimateFocus && { - transition: 'outline-color 0.2s, outline-offset 0.25s' - }), - ...outlineStyle, - '&:hover, &:active': { - // apply the same style so it's not overridden by some global style - ...outlineStyle - }, - ...(typeof withFocusOutline === 'undefined' && { - // user focuses the element - '&:focus': visibleFocusStyle - }), - ...(focusWithin && { - '&:focus-within': visibleFocusStyle - }) - } -} - /** * Generates the style object from the theme and provided additional information * @param {Object} componentTheme The theme variable object. @@ -229,8 +226,9 @@ const getFocusStyles = (props: ViewProps, componentTheme: ViewTheme) => { * @return {Object} The final style object, which will be used in the component */ const generateStyle = ( - componentTheme: ViewTheme, - props: ViewProps + componentTheme: NewComponentTypes['View'], + props: ViewProps, + sharedTokens: SharedTokens ): ViewStyle => { const { borderRadius, @@ -261,7 +259,7 @@ const generateStyle = ( dir } = props const borderStyle = getBorderStyle({ - theme: componentTheme, + sharedTokens, borderRadius, borderWidth, dir @@ -269,7 +267,7 @@ const generateStyle = ( const spacingStyle = getSpacingStyle({ margin, padding, - theme: componentTheme, + sharedTokens, dir }) @@ -404,18 +402,31 @@ const generateStyle = ( const shadowVariants = { topmost: { - boxShadow: componentTheme.shadowTopmost + boxShadow: boxShadowObjectsToCSSString(sharedTokens.boxShadow.elevation4) }, resting: { - boxShadow: componentTheme.shadowResting + boxShadow: boxShadowObjectsToCSSString(sharedTokens.boxShadow.elevation1) }, above: { - boxShadow: componentTheme.shadowAbove + boxShadow: boxShadowObjectsToCSSString(sharedTokens.boxShadow.elevation2) }, none: {} } - const focusStyles = getFocusStyles(props, componentTheme) + const { + focusColor, + focusPosition, + shouldAnimateFocus, + withFocusOutline, + focusWithin + } = props + const focusOutline = calcFocusOutlineStyles(sharedTokens.focusOutline, { + focusColor, + focusPosition, + shouldAnimateFocus, + focusWithin, + withFocusOutline + }) return { view: { label: 'view', @@ -439,7 +450,7 @@ const generateStyle = ( : {}), ...(withBorder(props) ? { - borderStyle: componentTheme.borderStyle, + borderStyle: 'solid', ...(borderColorVariants[borderColor!] || { borderColor: borderColor }) @@ -451,16 +462,16 @@ const generateStyle = ( //every '&' symbol will add another class to the rule, so it will be stronger //making an accidental override less likely '&&&&&&&&&&': { - ...spacingStyle, ...offsetStyle, - ...focusStyles, + ...focusOutline, width, height, minWidth, minHeight, maxWidth, maxHeight, - ...getStyleProps(props) + ...getStyleProps(props), + ...spacingStyle } } } diff --git a/packages/ui-view/src/View/theme.ts b/packages/ui-view/src/View/theme.ts deleted file mode 100644 index efe5ede39a..0000000000 --- a/packages/ui-view/src/View/theme.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015 - present Instructure, Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -/* Global variables (colors, typography, spacing, etc.) are defined in lib/themes */ - -import { makeThemeVars } from '@instructure/emotion' -import type { Theme, ThemeSpecificStyle } from '@instructure/ui-themes' -import { ViewTheme } from '@instructure/shared-types' - -/** - * Generates the theme object for the component from the theme and provided additional information - * @param {Object} theme The actual theme object. - * @return {Object} The final theme object with the overrides and component variables - */ -const generateComponentTheme = (theme: Theme): ViewTheme => { - const { - colors, - typography, - borders, - breakpoints, - spacing, - shadows, - stacking, - key: themeName - } = theme - - const themeSpecificStyle: ThemeSpecificStyle = { - canvas: { - color: theme['ic-brand-font-color-dark'], - focusColorInfo: theme['ic-brand-primary'], - backgroundBrand: theme['ic-brand-primary'], - backgroundInfo: theme['ic-brand-primary'], - borderColorBrand: theme['ic-brand-primary'], - borderColorInfo: theme['ic-brand-primary'] - } - } - - const componentVariables: ViewTheme = { - fontFamily: typography?.fontFamily, - - color: colors?.contrasts?.grey125125, - colorPrimaryInverse: colors?.contrasts?.white1010, - - borderColorPrimary: colors?.contrasts?.grey1424, - borderColorSecondary: colors?.contrasts?.grey3045, - borderColorSuccess: colors?.contrasts?.green4570, - borderColorBrand: colors?.contrasts?.blue4570, - borderColorInfo: colors?.contrasts?.blue4570, - borderColorAlert: colors?.contrasts?.blue4570, - borderColorWarning: colors?.contrasts?.orange4570, - borderColorDanger: colors?.contrasts?.red4570, - borderColorTransparent: 'transparent', - - debugOutlineColor: colors?.contrasts?.red4570, - - backgroundPrimary: colors?.contrasts?.white1010, - backgroundSecondary: colors?.contrasts?.grey1111, - backgroundPrimaryInverse: colors?.contrasts?.grey125125, - backgroundBrand: colors?.contrasts?.blue4570, - backgroundInfo: colors?.contrasts?.blue4570, - backgroundAlert: colors?.contrasts?.blue4570, - backgroundSuccess: colors?.contrasts?.green4570, - backgroundDanger: colors?.contrasts?.red4570, - backgroundWarning: colors?.contrasts?.orange4570, - - arrowSize: '0.5rem', - - focusOutlineStyle: borders?.style, - focusOutlineWidth: borders?.widthMedium, - focusOutlineOffset: '0.3125rem', - focusOutlineInset: '0rem', // do not use unitless zero (for CSS calc()) - - focusColorInfo: colors?.contrasts?.blue4570, - focusColorDanger: colors?.contrasts?.red4570, - focusColorSuccess: colors?.contrasts?.green4570, - focusColorInverse: colors?.contrasts?.white1010, - - xSmallMaxWidth: breakpoints?.xSmall, - smallMaxWidth: breakpoints?.small, - mediumMaxWidth: breakpoints?.medium, - largeMaxWidth: breakpoints?.large, - - ...makeThemeVars('margin', spacing), - ...makeThemeVars('padding', spacing), - ...makeThemeVars('shadow', shadows), - ...makeThemeVars('stacking', stacking), - ...makeThemeVars('border', borders) - } - - return { - ...componentVariables, - ...themeSpecificStyle[themeName] - } -} - -export default generateComponentTheme From 686397e2bf229379622bf30c3071dcf1e653dedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Tue, 16 Dec 2025 12:15:06 +0100 Subject: [PATCH 2/2] docs(ui-view): add View token changes to changelog + update hardcoded borderStyle to use token instead --- cypress/component/Tooltip.cy.tsx | 11 ++++++----- docs/guides/upgrade-guide.md | 17 ++++++++++++++++- packages/ui-view/src/ContextView/README.md | 2 -- packages/ui-view/src/View/styles.ts | 2 +- regression-test/src/app/contextview/page.tsx | 10 ++++------ regression-test/src/app/progressbar/page.tsx | 4 +--- 6 files changed, 28 insertions(+), 18 deletions(-) diff --git a/cypress/component/Tooltip.cy.tsx b/cypress/component/Tooltip.cy.tsx index 1d400d0011..4a94d5e135 100644 --- a/cypress/component/Tooltip.cy.tsx +++ b/cypress/component/Tooltip.cy.tsx @@ -245,11 +245,12 @@ describe('', () => { cy.get(tooltip).should('not.be.visible') - cy.get('[data-testid="trigger"]') - .realHover() - .then(() => { - cy.get(tooltip).should('be.visible') - }) + cy.get('[data-testid="trigger"]').realHover() + + // Verify tooltip is rendered and accessible (avoid Cypress's "covered by" check) + cy.get(tooltip).should('exist') + cy.get(tooltip).should('have.css', 'display', 'block') + cy.contains('Hello. I\'m a tool tip').should('exist') cy.get(tooltip) .realPress('Escape') diff --git a/docs/guides/upgrade-guide.md b/docs/guides/upgrade-guide.md index 5519666d9d..72be464bd4 100644 --- a/docs/guides/upgrade-guide.md +++ b/docs/guides/upgrade-guide.md @@ -135,9 +135,10 @@ type: example ``` + ### Breadcrumb -#### New tokens +#### New tokens - gapSm - Gap spacing for small size breadcrumbs - gapMd - Gap spacing for medium size breadcrumbs @@ -281,6 +282,20 @@ type: example - theme variable `borderStyle` is now removed - theme variable `position` is now removed +### View + +#### Theme variable changes + +| Old Variable | Status | Notes | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------- | +| `arrowSize` | Removed | Moved to ContextView component | +| `marginXxxSmall`, `marginXxSmall`, `marginXSmall`, `marginSmall`, `marginMedium`, `marginLarge`, `marginXLarge`, `marginXxLarge` | Removed | Use `sharedTokens.spacing` | +| `paddingXxxSmall`, `paddingXxSmall`, `paddingXSmall`, `paddingSmall`, `paddingMedium`, `paddingLarge`, `paddingXLarge`, `paddingXxLarge` | Removed | Use `sharedTokens.spacing` | +| `shadowDepth1`, `shadowDepth2`, `shadowDepth3` | Removed | Use `sharedTokens.boxShadow.elevation1/2/3` | +| `shadowResting`, `shadowAbove`, `shadowTopmost` | Removed | Use `sharedTokens.boxShadow.elevation*` | +| `borderRadiusSmall`, `borderRadiusMedium`, `borderRadiusLarge` | Removed | Use `sharedTokens.radius*` | +| `borderWidthSmall`, `borderWidthMedium`, `borderWidthLarge` | Removed | Use `sharedTokens.width*` | + ## Codemods To ease the upgrade, we provide codemods that will automate most of the changes. Pay close attention to its output, it cannot refactor complex code! The codemod scripts can be run via the following commands: diff --git a/packages/ui-view/src/ContextView/README.md b/packages/ui-view/src/ContextView/README.md index 620bcbc270..5f012832e1 100644 --- a/packages/ui-view/src/ContextView/README.md +++ b/packages/ui-view/src/ContextView/README.md @@ -47,9 +47,7 @@ type: example width="30rem" margin="x-large 0 0" > - This ContextView uses the inverse background and medium padding. Its width prop is set to `30rem`, which causes long strings like this to wrap. It also has top margin to separate it from the ContextViews above it. - ``` diff --git a/packages/ui-view/src/View/styles.ts b/packages/ui-view/src/View/styles.ts index fd3f17718e..f9a9618fcc 100644 --- a/packages/ui-view/src/View/styles.ts +++ b/packages/ui-view/src/View/styles.ts @@ -450,7 +450,7 @@ const generateStyle = ( : {}), ...(withBorder(props) ? { - borderStyle: 'solid', + borderStyle: componentTheme.borderStyle, ...(borderColorVariants[borderColor!] || { borderColor: borderColor }) diff --git a/regression-test/src/app/contextview/page.tsx b/regression-test/src/app/contextview/page.tsx index 3f70908b5c..e5caba697b 100644 --- a/regression-test/src/app/contextview/page.tsx +++ b/regression-test/src/app/contextview/page.tsx @@ -68,12 +68,10 @@ export default function ContextViewPage() { width="30rem" margin="x-large 0 0" > - - This ContextView uses the inverse background and medium padding. Its - width prop is set to 30rem, which causes long strings like this to - wrap. It also has top margin to separate it from the ContextViews - above it. - + This ContextView uses the inverse background and medium padding. Its + width prop is set to 30rem, which causes long strings like this to + wrap. It also has top margin to separate it from the ContextViews + above it. ) diff --git a/regression-test/src/app/progressbar/page.tsx b/regression-test/src/app/progressbar/page.tsx index c6d92c7c1d..457c527118 100644 --- a/regression-test/src/app/progressbar/page.tsx +++ b/regression-test/src/app/progressbar/page.tsx @@ -101,9 +101,7 @@ export default function ProgressBarPage() { color="primary-inverse" valueNow={30} valueMax={60} - renderValue={({ valueNow, valueMax }: any) => ( - {Math.round((valueNow / valueMax) * 100)}% - )} + renderValue={({ valueNow, valueMax }: any) => `${Math.round((valueNow / valueMax) * 100)}%`} formatScreenReaderValue={({ valueNow, valueMax }: any) => Math.round((valueNow / valueMax) * 100) + ' percent' }