Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/ui-elements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"lint": "eslint 'src/**/*.js'",
"lint:fix": "eslint 'src/**/*.js' --fix",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"test": "node --test src/components/**/*.test.mjs"
},
"author": "",
"license": "ISC",
Expand Down
22 changes: 11 additions & 11 deletions packages/ui-elements/src/components/ActionButton/ActionButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,7 @@ import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import { Button } from '../Button';
import { Icon } from '../Icon';

const getSize = ({ small, large, size }) => {
if (small) {
return '1.25rem';
}
if (large) {
return '1.75rem';
}
return size || '1.5rem';
};
import getIconSize from './getIconSize';

const ActionButton = forwardRef(
(
Expand All @@ -27,7 +18,7 @@ const ActionButton = forwardRef(
) => (
<Button ref={ref} square size={size} type={color} {...props}>
{children}
<Icon name={icon} size={getSize(iconSize)} />
<Icon name={icon} size={getIconSize(iconSize, size)} />
</Button>
)
);
Expand All @@ -41,6 +32,15 @@ ActionButton.propTypes = {
PropTypes.node,
]),
size: PropTypes.oneOf(['small', 'medium', 'large']),
iconSize: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
small: PropTypes.bool,
large: PropTypes.bool,
size: PropTypes.string,
}),
]),
};

export { getIconSize };
export default ActionButton;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import getIconSize from './getIconSize.js';

test('ActionButton: derives small icon size (1rem) for small action button to prevent container overflow', () => {
assert.strictEqual(getIconSize({}, 'small'), '1rem');
});

test('ActionButton: derives medium icon size (1.25rem) by default', () => {
assert.strictEqual(getIconSize({}, 'medium'), '1.25rem');
assert.strictEqual(getIconSize(), '1.25rem');
});

test('ActionButton: derives large icon size (1.75rem) for large action button', () => {
assert.strictEqual(getIconSize({}, 'large'), '1.75rem');
});

test('ActionButton: supports named iconSize strings ("small", "medium", "large")', () => {
assert.strictEqual(getIconSize('small', 'medium'), '1rem');
assert.strictEqual(getIconSize('medium', 'medium'), '1.25rem');
assert.strictEqual(getIconSize('large', 'medium'), '1.75rem');
});

test('ActionButton: supports custom iconSize css length string', () => {
assert.strictEqual(getIconSize('1.4rem', 'medium'), '1.4rem');
assert.strictEqual(getIconSize('18px', 'small'), '18px');
});

test('ActionButton: supports legacy object syntax { small: true } and { large: true }', () => {
assert.strictEqual(getIconSize({ small: true }), '1rem');
assert.strictEqual(getIconSize({ large: true }), '1.75rem');
});

test('ActionButton: supports explicit size property in iconSize object { size: "1.3rem" }', () => {
assert.strictEqual(getIconSize({ size: '1.3rem' }), '1.3rem');
});
35 changes: 35 additions & 0 deletions packages/ui-elements/src/components/ActionButton/getIconSize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const getIconSize = (iconSize, buttonSize) => {
if (typeof iconSize === 'string') {
if (iconSize === 'small') {
return '1rem';
}
if (iconSize === 'medium') {
return '1.25rem';
}
if (iconSize === 'large') {
return '1.75rem';
}
return iconSize;
}
if (iconSize && typeof iconSize === 'object') {
if (iconSize.size) {
return iconSize.size;
}
if (iconSize.small) {
return '1rem';
}
if (iconSize.large) {
return '1.75rem';
}
}
if (buttonSize === 'small') {
return '1rem';
}
if (buttonSize === 'large') {
return '1.75rem';
}
return '1.25rem';
};

export default getIconSize;

76 changes: 68 additions & 8 deletions packages/ui-elements/src/components/Tooltip/Tooltip.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { css } from '@emotion/react';
import PropTypes from 'prop-types';
import { Box } from '../Box';
import getToolTipStyles from './Tooltip.styles';
import { useTheme } from '../../hooks';

const Tooltip = ({ children, text, position }) => {
const Tooltip = ({
children,
text,
position = 'top',
className = '',
style = {},
...props
}) => {
const [isTooltipVisible, setTooltipVisible] = useState(false);
const touchTimerRef = useRef(null);
const { theme } = useTheme();
const styles = getToolTipStyles(theme, position);

Expand All @@ -17,40 +26,91 @@ const Tooltip = ({ children, text, position }) => {
setTooltipVisible(false);
};

let touchTimer;
const handleFocus = () => {
setTooltipVisible(true);
};

const handleBlur = () => {
setTooltipVisible(false);
};

const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setTooltipVisible(false);
}
};

const handleTouchStart = () => {
touchTimer = setTimeout(() => {
touchTimerRef.current = setTimeout(() => {
setTooltipVisible(true);
}, 500);
};

const handleTouchEnd = () => {
clearTimeout(touchTimer);
if (touchTimerRef.current) {
clearTimeout(touchTimerRef.current);
touchTimerRef.current = null;
}
setTooltipVisible(false);
};

useEffect(
() => () => {
if (touchTimerRef.current) {
clearTimeout(touchTimerRef.current);
}
},
[]
);

const hasText = Boolean(
text && (typeof text === 'string' ? text.trim().length > 0 : true)
);
const formattedText =
typeof text === 'string' && text.length > 0
? text.charAt(0).toUpperCase() + text.slice(1)
: text;

return (
<Box
is="span"
className={className}
style={style}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
css={css`
position: relative;
display: inline-block;
`}
{...props}
>
{children}
{isTooltipVisible && (
<Box is="span" css={styles.tooltip}>
{text.charAt(0).toUpperCase() + text.slice(1)}
{hasText && isTooltipVisible && (
<Box
is="span"
role="tooltip"
aria-hidden={!isTooltipVisible}
css={styles.tooltip}
>
{formattedText}
<Box is="span" css={styles.tooltipArrow} />
</Box>
)}
</Box>
);
};

Tooltip.propTypes = {
children: PropTypes.node,
text: PropTypes.node,
position: PropTypes.oneOf(['top', 'bottom', 'down']),
className: PropTypes.string,
style: PropTypes.object,
};

export default Tooltip;
30 changes: 21 additions & 9 deletions packages/ui-elements/src/components/Tooltip/Tooltip.styles.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { css } from '@emotion/react';

const getTooltipStyles = (theme, position) => {
const getTooltipStyles = (theme, position = 'top') => {
const isBottom = position === 'bottom' || position === 'down';

const styles = {
tooltip: css`
position: absolute;
left: 64%;
left: 50%;
transform: translateX(-50%);
background-color: ${theme.invertedColors.secondary};
color: ${theme.invertedColors.secondaryForeground};
Expand All @@ -16,22 +18,32 @@ const getTooltipStyles = (theme, position) => {
font-weight: 500;
white-space: nowrap;
font-family: sans-serif;
top: ${position === 'top' ? 'calc(-100% - 20px)' : 'calc(100% + 10px)'};
pointer-events: none;
${isBottom
? css`
top: calc(100% + 8px);
`
: css`
bottom: calc(100% + 8px);
`}
`,
tooltipArrow: css`
content: '';
position: absolute;
left: 50%;
margin-left: -4px;
border-width: 6px;
border-style: solid;
border-color: ${theme.invertedColors.secondary} transparent transparent
transparent;
top: ${position === 'top' ? '100%' : 'auto'};
bottom: ${position === 'bottom' ? '100%' : 'auto'};
transform: ${position === 'bottom'
? 'translateX(-50%) rotate(180deg)'
: 'translateX(-50%)'};
${isBottom
? css`
bottom: 100%;
transform: translateX(-50%) rotate(180deg);
`
: css`
top: 100%;
transform: translateX(-50%);
`}
`,
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import getTooltipStyles from './Tooltip.styles.js';

const mockTheme = {
radius: '4px',
invertedColors: {
secondary: '#1f2328',
secondaryForeground: '#ffffff',
},
zIndex: {
tooltip: 1400,
},
};

test('Tooltip.styles: centers tooltip horizontally with left: 50% and translateX(-50%)', () => {
const styles = getTooltipStyles(mockTheme, 'top');
const tooltipCss = styles.tooltip.styles;

assert.match(tooltipCss, /left:\s*50%/);
assert.match(tooltipCss, /transform:\s*translateX\(-50%\)/);
assert.doesNotMatch(
tooltipCss,
/left:\s*64%/,
'Should not use hardcoded 64% offset'
);
});

test('Tooltip.styles: positions top tooltip above anchor element', () => {
const styles = getTooltipStyles(mockTheme, 'top');
const tooltipCss = styles.tooltip.styles;
const arrowCss = styles.tooltipArrow.styles;

assert.match(tooltipCss, /bottom:\s*calc\(100%\s*\+\s*8px\)/);
assert.match(arrowCss, /top:\s*100%/);
assert.match(arrowCss, /transform:\s*translateX\(-50%\)/);
});

test('Tooltip.styles: positions bottom tooltip below anchor element with inverted arrow', () => {
const styles = getTooltipStyles(mockTheme, 'bottom');
const tooltipCss = styles.tooltip.styles;
const arrowCss = styles.tooltipArrow.styles;

assert.match(tooltipCss, /top:\s*calc\(100%\s*\+\s*8px\)/);
assert.match(arrowCss, /bottom:\s*100%/);
assert.match(arrowCss, /rotate\(180deg\)/);
});

test('Tooltip.styles: normalizes position "down" to behave identically to "bottom"', () => {
const downStyles = getTooltipStyles(mockTheme, 'down');
const bottomStyles = getTooltipStyles(mockTheme, 'bottom');

assert.strictEqual(downStyles.tooltip.styles, bottomStyles.tooltip.styles);
assert.strictEqual(
downStyles.tooltipArrow.styles,
bottomStyles.tooltipArrow.styles
);
});

test('Tooltip.styles: defaults to "top" position when position argument is omitted', () => {
const defaultStyles = getTooltipStyles(mockTheme);
const topStyles = getTooltipStyles(mockTheme, 'top');

assert.strictEqual(defaultStyles.tooltip.styles, topStyles.tooltip.styles);
assert.strictEqual(
defaultStyles.tooltipArrow.styles,
topStyles.tooltipArrow.styles
);
});

test('Tooltip.styles: includes pointer-events: none to avoid hover jitter and blocking clicks', () => {
const styles = getTooltipStyles(mockTheme, 'top');
assert.match(styles.tooltip.styles, /pointer-events:\s*none/);
});
Loading