diff --git a/.changeset/silly-dragons-buy.md b/.changeset/silly-dragons-buy.md new file mode 100644 index 000000000..89ffff745 --- /dev/null +++ b/.changeset/silly-dragons-buy.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Apply various fixes to the new scrollbar style. diff --git a/src/tasty/__snapshots__/tasty.test.tsx.snap b/src/tasty/__snapshots__/tasty.test.tsx.snap index ec403e725..075b52137 100644 --- a/src/tasty/__snapshots__/tasty.test.tsx.snap +++ b/src/tasty/__snapshots__/tasty.test.tsx.snap @@ -22,7 +22,7 @@ exports[`tasty() API should allow multiple wrapping 1`] = ` } .c0.c0 { - color: var(--white-color, rgb(0 0 0 / 1)); + color: var(--white-color); --current-color: var(--white-color, white); --current-color-rgb: var(--white-color-rgb); } @@ -120,7 +120,7 @@ exports[`tasty() API should create element styles 1`] = ` } .c0.c0[data-is-modified] [data-element="Element"] { - color: var(--purple-color, rgb(0 0 0 / 1)); + color: var(--purple-color); --current-color: var(--purple-color, purple); --current-color-rgb: var(--purple-color-rgb); } @@ -190,7 +190,7 @@ exports[`tasty() API should fallback to default variant 1`] = ` } .c0.c0 { - color: var(--white-color, rgb(0 0 0 / 1)); + color: var(--white-color); --current-color: var(--white-color, white); --current-color-rgb: var(--white-color-rgb); } @@ -226,7 +226,7 @@ exports[`tasty() API should merge element styles 1`] = ` } .c0.c0[data-is-modified] [data-element="Element"] { - color: var(--purple-color, rgb(0 0 0 / 1)); + color: var(--purple-color); --current-color: var(--purple-color, purple); --current-color-rgb: var(--purple-color-rgb); } @@ -263,7 +263,7 @@ exports[`tasty() API should merge styles 1`] = ` } .c0.c0[data-is-modified] { - color: var(--purple-color, rgb(0 0 0 / 1)); + color: var(--purple-color); --current-color: var(--purple-color, purple); --current-color-rgb: var(--purple-color-rgb); } @@ -300,7 +300,7 @@ exports[`tasty() API should merge styles in custom prop 1`] = ` } .c0.c0[data-is-modified] { - color: var(--purple-color, rgb(0 0 0 / 1)); + color: var(--purple-color); --current-color: var(--purple-color, purple); --current-color-rgb: var(--purple-color-rgb); } @@ -367,7 +367,7 @@ exports[`tasty() API should support camelCase modifiers 1`] = ` } .c0.c0[data-is-somehow-modified] { - color: var(--purple-color, rgb(0 0 0 / 1)); + color: var(--purple-color); --current-color: var(--purple-color, purple); --current-color-rgb: var(--purple-color-rgb); } @@ -400,7 +400,7 @@ exports[`tasty() API should support kebab-case modifiers 1`] = ` } .c0.c0[data-is-somehow-modified] { - color: var(--purple-color, rgb(0 0 0 / 1)); + color: var(--purple-color); --current-color: var(--purple-color, purple); --current-color-rgb: var(--purple-color-rgb); } @@ -433,7 +433,7 @@ exports[`tasty() API should support modifiers 1`] = ` } .c0.c0[data-is-modified] { - color: var(--purple-color, rgb(0 0 0 / 1)); + color: var(--purple-color); --current-color: var(--purple-color, purple); --current-color-rgb: var(--purple-color-rgb); } diff --git a/src/tasty/styles.test.ts b/src/tasty/styles.test.ts index c27ede977..cb66ce1b4 100644 --- a/src/tasty/styles.test.ts +++ b/src/tasty/styles.test.ts @@ -115,70 +115,4 @@ describe('Tasty style tests', () => { 'margin-bottom': '1rem', }); }); - - it('should return undefined for undefined scrollbar', () => { - expect(scrollbarStyle({})).toBeUndefined(); - }); - - it('should return correct styles for two colors', () => { - expect(scrollbarStyle({ scrollbar: '#dark #clear' })).toEqual({ - 'scrollbar-color': 'var(--dark-color) var(--clear-color)', - '&::-webkit-scrollbar-corner': { - background: 'var(--clear-color)', - }, - }); - }); - - it('should return correct styles for `thin` scrollbar', () => { - expect(scrollbarStyle({ scrollbar: 'thin' })).toEqual({ - 'scrollbar-width': 'thin', - 'scrollbar-color': 'var(--scrollbar-thumb-color) transparent', - }); - }); - - it('should return correct styles for `none` scrollbar', () => { - expect(scrollbarStyle({ scrollbar: 'none' })).toEqual({ - 'scrollbar-color': 'var(--scrollbar-thumb-color) transparent', - '&::-webkit-scrollbar': { - width: 'none', - height: 'none', - }, - }); - }); - - it('should handle custom overflow with scrollbar', () => { - expect(scrollbarStyle({ scrollbar: 'always', overflow: 'scroll' })).toEqual( - { - overflow: 'scroll', - 'scrollbar-gutter': 'always', - 'scrollbar-color': 'var(--scrollbar-thumb-color) transparent', - }, - ); - }); - - it('should handle styled scrollbar', () => { - expect(scrollbarStyle({ scrollbar: 'styled' })).toEqual({ - 'scrollbar-width': 'thin', - 'scrollbar-color': 'var(--scrollbar-thumb-color) transparent', - '&::-webkit-scrollbar': { - width: '8px', - height: '8px', - background: 'var(--scrollbar-track-color)', - transition: - 'background var(--transition), border-radius var(--transition), box-shadow var(--transition), width var(--transition), height var(--transition), border var(--transition)', - }, - '&::-webkit-scrollbar-thumb': { - background: 'var(--scrollbar-thumb-color)', - borderRadius: '8px', - minHeight: '24px', - transition: - 'background var(--transition), border-radius var(--transition), box-shadow var(--transition), width var(--transition), height var(--transition), border var(--transition)', - }, - '&::-webkit-scrollbar-corner': { - background: 'var(--scrollbar-track-color)', - transition: - 'background var(--transition), border-radius var(--transition), box-shadow var(--transition), width var(--transition), height var(--transition), border var(--transition)', - }, - }); - }); }); diff --git a/src/tasty/styles/scrollbar.test.ts b/src/tasty/styles/scrollbar.test.ts new file mode 100644 index 000000000..b18e02d2a --- /dev/null +++ b/src/tasty/styles/scrollbar.test.ts @@ -0,0 +1,109 @@ +import { scrollbarStyle } from './scrollbar'; + +describe('scrollbarStyle', () => { + it('returns undefined when scrollbar is not defined', () => { + expect(scrollbarStyle({})).toBeUndefined(); + }); + + it('handles boolean true value as thin', () => { + const result = scrollbarStyle({ scrollbar: true }); + expect(result['scrollbar-width']).toBe('thin'); + }); + + it('handles number value as size', () => { + const result = scrollbarStyle({ scrollbar: 10 }); + expect(result['&::-webkit-scrollbar']['width']).toBe('10'); + expect(result['&::-webkit-scrollbar']['height']).toBe('10'); + }); + + it('handles "none" modifier', () => { + const result = scrollbarStyle({ scrollbar: 'none' }); + expect(result['scrollbar-width']).toBe('none'); + expect(result['scrollbar-color']).toBe('transparent transparent'); + expect(result['&::-webkit-scrollbar']['width']).toBe('0px'); + }); + + it('handles "styled" modifier with proper defaults', () => { + const result = scrollbarStyle({ scrollbar: 'styled' }); + expect(result['scrollbar-width']).toBe('thin'); + expect(result['&::-webkit-scrollbar']['width']).toBe('8px'); + expect(result['&::-webkit-scrollbar-thumb']['border-radius']).toBe('8px'); + expect(result['&::-webkit-scrollbar-thumb']['min-height']).toBe('24px'); + }); + + it('handles custom colors', () => { + const result = scrollbarStyle({ scrollbar: '#red #blue #green' }); + expect(result['scrollbar-color']).toBe( + 'var(--red-color) var(--blue-color)', + ); + expect(result['&::-webkit-scrollbar-track']['background']).toBe( + 'var(--blue-color)', + ); + expect(result['&::-webkit-scrollbar-thumb']['background']).toBe( + 'var(--red-color)', + ); + expect(result['&::-webkit-scrollbar-corner']['background']).toBe( + 'var(--green-color)', + ); + }); + + it('handles "always" modifier with overflow', () => { + const result = scrollbarStyle({ scrollbar: 'always', overflow: 'auto' }); + expect(result['overflow']).toBe('auto'); + expect(result['scrollbar-gutter']).toBe('stable'); + expect(result['&::-webkit-scrollbar']['display']).toBe('block'); + }); + + it('combines modifiers correctly', () => { + const result = scrollbarStyle({ scrollbar: 'thin styled #red' }); + expect(result['scrollbar-width']).toBe('thin'); + expect(result['scrollbar-color']).toBe( + 'var(--red-color) var(--scrollbar-track-color, transparent)', + ); + expect(result['&::-webkit-scrollbar-thumb']['background']).toBe( + 'var(--red-color)', + ); + }); + + it('applies custom colors to styled scrollbars', () => { + const result = scrollbarStyle({ + scrollbar: 'styled #purple #dark #light-grey', + }); + expect(result['scrollbar-color']).toBe( + 'var(--purple-color) var(--dark-color)', + ); + expect(result['&::-webkit-scrollbar']['background']).toBe( + 'var(--dark-color)', + ); + expect(result['&::-webkit-scrollbar-track']['background']).toBe( + 'var(--dark-color)', + ); + expect(result['&::-webkit-scrollbar-thumb']['background']).toBe( + 'var(--purple-color)', + ); + expect(result['&::-webkit-scrollbar-corner']['background']).toBe( + 'var(--light-grey-color)', + ); + }); + + it('applies partial custom colors with defaults', () => { + const result = scrollbarStyle({ scrollbar: 'styled #danger' }); + // Only thumb color specified, track should use default + expect(result['scrollbar-color']).toBe( + 'var(--danger-color) var(--scrollbar-track-color, transparent)', + ); + expect(result['&::-webkit-scrollbar-thumb']['background']).toBe( + 'var(--danger-color)', + ); + expect(result['&::-webkit-scrollbar-track']['background']).toBe( + 'var(--scrollbar-track-color, transparent)', + ); + }); + + it('ensures all CSS properties are kebab-cased', () => { + const result = scrollbarStyle({ scrollbar: 'styled thin' }); + // Check that camelCase properties are converted to kebab-case + expect(result['&::-webkit-scrollbar-thumb']['border-radius']).toBe('8px'); + expect(result['&::-webkit-scrollbar-thumb']['min-height']).toBe('24px'); + }); +}); diff --git a/src/tasty/styles/scrollbar.ts b/src/tasty/styles/scrollbar.ts index 57faef598..49be1945a 100644 --- a/src/tasty/styles/scrollbar.ts +++ b/src/tasty/styles/scrollbar.ts @@ -1,41 +1,63 @@ import { parseStyle } from '../utils/styles'; -export function scrollbarStyle({ - scrollbar, - overflow, -}: { +interface ScrollbarStyleProps { scrollbar?: string | boolean | number; overflow?: string; -}) { +} + +/** + * Creates cross-browser compatible scrollbar styles + * + * Supports both Firefox (scrollbar-width, scrollbar-color) and + * WebKit/Chromium browsers (::-webkit-scrollbar) + */ +export function scrollbarStyle({ scrollbar, overflow }: ScrollbarStyleProps) { // Check if scrollbar is defined if (!scrollbar && scrollbar !== 0) return; // Support true as alias for thin - let value = scrollbar === true ? 'thin' : scrollbar; + const value = scrollbar === true || scrollbar === '' ? 'thin' : scrollbar; const { mods, colors, values } = parseStyle(String(value)); const style = {}; - style['scrollbar-color'] = 'var(--scrollbar-thumb-color) transparent'; + // Default colors for scrollbar + const defaultThumbColor = 'var(--scrollbar-thumb-color)'; + const defaultTrackColor = 'var(--scrollbar-track-color, transparent)'; + + // Setup default Firefox scrollbar style + style['scrollbar-color'] = `${defaultThumbColor} transparent`; - // Modifiers + // Default scrollbar size + const defaultSize = '8px'; + const sizeValue = values[0] || defaultSize; + + // Process modifiers if (mods.includes('thin')) { style['scrollbar-width'] = 'thin'; - } - if (mods.includes('none')) { + } else if (values.includes('none')) { style['scrollbar-width'] = 'none'; style['scrollbar-color'] = 'transparent transparent'; - } - if (mods.includes('auto')) { + // Also hide WebKit scrollbars + style['&::-webkit-scrollbar'] = { + width: '0px', + height: '0px', + display: 'none', + }; + + return style; + } else if (mods.includes('auto')) { style['scrollbar-width'] = 'auto'; } + + // Handle scrollbar gutter behavior if (mods.includes('stable') || mods.includes('both-edges')) { + // scrollbar-gutter is supported in newer browsers only style['scrollbar-gutter'] = mods.includes('both-edges') ? 'stable both-edges' : 'stable'; } - // Custom size (all values are sizes) - const sizeValue = values[0]; + // Custom size setup for WebKit if (sizeValue) { style['&::-webkit-scrollbar'] = { ...(style['&::-webkit-scrollbar'] || {}), @@ -44,25 +66,57 @@ export function scrollbarStyle({ }; } - // Colors (support up to 3: thumb, track, corner) + // Extract colors (support up to 3: thumb, track, corner) + // These will be used in various places throughout the function + const thumbColor = colors && colors[0] ? colors[0] : defaultThumbColor; + const trackColor = colors && colors[1] ? colors[1] : defaultTrackColor; + const cornerColor = colors && colors[2] ? colors[2] : trackColor; + + // Apply colors if they are specified if (colors && colors.length) { - const thumb = colors[0] || 'var(--scrollbar-thumb-color)'; - const track = colors[1] || 'var(--scrollbar-track-color)'; - const corner = colors[2] || track; - style['scrollbar-color'] = `${thumb} ${track}`; - if (!style['&::-webkit-scrollbar-corner']) { - style['&::-webkit-scrollbar-corner'] = {}; + // Firefox + style['scrollbar-color'] = `${thumbColor} ${trackColor}`; + + // WebKit - always set these for consistency + if (!style['&::-webkit-scrollbar']) { + style['&::-webkit-scrollbar'] = {}; } - style['&::-webkit-scrollbar-corner'].background = corner; + style['&::-webkit-scrollbar']['background'] = trackColor; + + style['&::-webkit-scrollbar-track'] = { + ...(style['&::-webkit-scrollbar-track'] || {}), + background: trackColor, + }; + + style['&::-webkit-scrollbar-thumb'] = { + ...(style['&::-webkit-scrollbar-thumb'] || {}), + background: thumbColor, + }; + + style['&::-webkit-scrollbar-corner'] = { + ...(style['&::-webkit-scrollbar-corner'] || {}), + background: cornerColor, + }; } - // always: force scrollbars to show (requires overflow) + // Handle 'always' mode: force scrollbars to show if (mods.includes('always')) { style['overflow'] = overflow || 'scroll'; - style['scrollbar-gutter'] = style['scrollbar-gutter'] || 'always'; + + // Use auto for WebKit browsers since they don't support 'always' + // This is closer to the expected behavior + if (!style['scrollbar-gutter']) { + style['scrollbar-gutter'] = 'stable'; + } + + // Ensure scrollbars appear in WebKit even with little content + if (!style['&::-webkit-scrollbar']) { + style['&::-webkit-scrollbar'] = {}; + } + style['&::-webkit-scrollbar']['display'] = 'block'; } - // Legacy styled mod + // Enhanced 'styled' mode with better transitions and appearance if (mods.includes('styled')) { const baseTransition = [ 'background var(--transition)', @@ -72,27 +126,39 @@ export function scrollbarStyle({ 'height var(--transition)', 'border var(--transition)', ].join(', '); + + // Firefox style['scrollbar-width'] = style['scrollbar-width'] || 'thin'; style['scrollbar-color'] = - style['scrollbar-color'] || - 'var(--scrollbar-thumb-color) var(--scrollbar-track-color)'; + style['scrollbar-color'] || `${defaultThumbColor} ${defaultTrackColor}`; + + // WebKit style['&::-webkit-scrollbar'] = { - ...(style['&::-webkit-scrollbar'] || {}), - width: sizeValue || '8px', - height: sizeValue || '8px', - background: 'var(--scrollbar-track-color)', + width: sizeValue, + height: sizeValue, transition: baseTransition, + background: defaultTrackColor, + ...(style['&::-webkit-scrollbar'] || {}), }; + style['&::-webkit-scrollbar-thumb'] = { - background: 'var(--scrollbar-thumb-color)', - borderRadius: '8px', - minHeight: '24px', + 'border-radius': '8px', + 'min-height': '24px', transition: baseTransition, + background: defaultThumbColor, + ...(style['&::-webkit-scrollbar-thumb'] || {}), }; + + style['&::-webkit-scrollbar-track'] = { + background: defaultTrackColor, + transition: baseTransition, + ...(style['&::-webkit-scrollbar-track'] || {}), + }; + style['&::-webkit-scrollbar-corner'] = { - ...(style['&::-webkit-scrollbar-corner'] || {}), - background: 'var(--scrollbar-track-color)', + background: defaultTrackColor, transition: baseTransition, + ...(style['&::-webkit-scrollbar-corner'] || {}), }; } diff --git a/src/tasty/utils/styles.ts b/src/tasty/utils/styles.ts index 2566def33..be3135857 100644 --- a/src/tasty/utils/styles.ts +++ b/src/tasty/utils/styles.ts @@ -590,8 +590,8 @@ export function hexToRgb(hex) { .match(/.{2}/g) .map((x, i) => parseInt(x, 16) * (i === 3 ? 1 / 255 : 1)); - if (Number.isNaN(rgba[0])) { - return 'rgb(0 0 0 / 1)'; + if (rgba.some((v) => Number.isNaN(v))) { + return null; } if (rgba.length >= 3) {