@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
315 lines • 14.4 kB
JavaScript
import { Shade, createComponent } from '@furystack/shades';
import { buildTransition, cssVariableTheme } from '../services/css-variable-theme.js';
import { paletteFullColors } from '../services/palette-css-vars.js';
const groupChildRadius = cssVariableTheme.shape.borderRadius.md;
export const ButtonGroup = Shade({
customElementName: 'shade-button-group',
css: {
display: 'inline-flex',
fontFamily: cssVariableTheme.typography.fontFamily,
borderRadius: cssVariableTheme.shape.borderRadius.md,
'&[data-orientation="vertical"]': {
flexDirection: 'column',
},
// Uses [role][data-orientation] for specificity (0,2,1) to override
// child Button CSS at (0,1,1)
'&[role][data-orientation] > *': {
margin: '0',
borderRadius: '0',
},
'&[role]:not([data-orientation="vertical"]) > :first-child': {
borderRadius: `${groupChildRadius} 0 0 ${groupChildRadius}`,
},
'&[role]:not([data-orientation="vertical"]) > :last-child': {
borderRadius: `0 ${groupChildRadius} ${groupChildRadius} 0`,
},
'&[role][data-orientation="vertical"] > :first-child': {
borderRadius: `${groupChildRadius} ${groupChildRadius} 0 0`,
},
'&[role][data-orientation="vertical"] > :last-child': {
borderRadius: `0 0 ${groupChildRadius} ${groupChildRadius}`,
},
'&[role][data-orientation] > :only-child': {
borderRadius: groupChildRadius,
},
},
render: ({ props, children, useHostProps }) => {
const { orientation = 'horizontal', disabled, variant, color, style } = props;
useHostProps({
role: 'group',
'data-orientation': orientation,
'data-variant': variant || undefined,
'data-disabled': disabled ? '' : undefined,
'data-color': color || undefined,
...(style ? { style: style } : {}),
});
return createComponent(createComponent, null, children);
},
});
export const ToggleButton = Shade({
customElementName: 'shade-toggle-button',
elementBase: HTMLButtonElement,
elementBaseName: 'button',
css: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: `${cssVariableTheme.spacing.sm} ${cssVariableTheme.spacing.lg}`,
border: 'none',
borderRadius: '0',
margin: '0',
fontSize: cssVariableTheme.typography.fontSize.md,
fontWeight: cssVariableTheme.typography.fontWeight.medium,
letterSpacing: cssVariableTheme.typography.letterSpacing.wider,
lineHeight: '1.75',
cursor: 'pointer',
userSelect: 'none',
background: 'transparent',
color: 'var(--toggle-color-main)',
boxShadow: 'none',
transition: buildTransition(['background', cssVariableTheme.transitions.duration.normal, cssVariableTheme.transitions.easing.default], ['color', cssVariableTheme.transitions.duration.normal, cssVariableTheme.transitions.easing.default], ['box-shadow', cssVariableTheme.transitions.duration.normal, cssVariableTheme.transitions.easing.default]),
'&[data-grouped]': {
boxShadow: '0px 0px 0px 1px var(--toggle-color-main)',
},
'&:hover:not(:disabled):not([data-selected])': {
background: 'color-mix(in srgb, var(--toggle-color-main) 10%, transparent)',
},
'&[data-selected]': {
background: 'color-mix(in srgb, var(--toggle-color-main) 20%, transparent)',
color: 'var(--toggle-color-main)',
fontWeight: cssVariableTheme.typography.fontWeight.semibold,
},
'&[data-selected]:hover:not(:disabled)': {
background: 'color-mix(in srgb, var(--toggle-color-main) 30%, transparent)',
},
'&:disabled': {
cursor: 'not-allowed',
opacity: cssVariableTheme.action.disabledOpacity,
},
'&:active:not(:disabled)': {
transform: 'scale(0.96)',
},
'&[data-size="small"]': {
padding: `${cssVariableTheme.spacing.xs} ${cssVariableTheme.spacing.sm}`,
fontSize: cssVariableTheme.typography.fontSize.sm,
},
'&[data-size="large"]': {
padding: `${cssVariableTheme.spacing.md} ${cssVariableTheme.spacing.xl}`,
fontSize: cssVariableTheme.typography.fontSize.lg,
},
},
render: ({ props, children, useHostProps }) => {
useHostProps({
'data-value': props.value || undefined,
'data-size': props.size && props.size !== 'medium' ? props.size : undefined,
'data-selected': props.pressed ? '' : undefined,
type: 'button',
style: {
'--toggle-color-main': cssVariableTheme.text.secondary,
...props.style,
},
});
return createComponent(createComponent, null, children);
},
});
const defaultToggleColors = {
main: cssVariableTheme.text.secondary,
mainContrast: cssVariableTheme.background.default,
light: cssVariableTheme.text.primary,
dark: cssVariableTheme.text.disabled,
};
export const ToggleButtonGroup = Shade({
customElementName: 'shade-toggle-button-group',
css: {
display: 'inline-flex',
borderRadius: cssVariableTheme.shape.borderRadius.md,
'&[data-orientation="vertical"]': {
flexDirection: 'column',
},
'&[data-disabled]': {
pointerEvents: 'none',
opacity: cssVariableTheme.action.disabledOpacity,
},
// Grouped appearance: box-shadow border + reset border-radius.
// Uses [data-value] attribute selector for specificity (0,1,2) to
// override ToggleButton's own CSS at (0,1,1).
'& button[data-value]': {
boxShadow: '0px 0px 0px 1px var(--toggle-color-main)',
borderRadius: '0',
},
'&:not([data-orientation="vertical"]) button:first-of-type': {
borderRadius: `${groupChildRadius} 0 0 ${groupChildRadius}`,
},
'&:not([data-orientation="vertical"]) button:last-of-type': {
borderRadius: `0 ${groupChildRadius} ${groupChildRadius} 0`,
},
'&[data-orientation="vertical"] button:first-of-type': {
borderRadius: `${groupChildRadius} ${groupChildRadius} 0 0`,
},
'&[data-orientation="vertical"] button:last-of-type': {
borderRadius: `0 0 ${groupChildRadius} ${groupChildRadius}`,
},
'& button:only-of-type': {
borderRadius: groupChildRadius,
},
},
render: ({ props, children, useDisposable, useHostProps, useRef }) => {
const groupRef = useRef('group');
// Mutable container so the click handler always reads the latest props
const state = useDisposable('state', () => ({
props,
[Symbol.dispose]: () => { },
}));
state.props = props;
useDisposable('click-handler', () => {
const handleClick = (ev) => {
const target = ev.target.closest('button[data-value]');
if (!target || target.hasAttribute('disabled'))
return;
const clickedValue = target.getAttribute('data-value');
if (!clickedValue)
return;
const currentProps = state.props;
if (currentProps.exclusive) {
const currentValue = Array.isArray(currentProps.value) ? currentProps.value[0] : currentProps.value;
const newValue = currentValue === clickedValue ? '' : clickedValue;
currentProps.onValueChange?.(newValue);
}
else {
const currentValues = Array.isArray(currentProps.value)
? currentProps.value
: currentProps.value
? [currentProps.value]
: [];
const newValues = currentValues.includes(clickedValue)
? currentValues.filter((v) => v !== clickedValue)
: [...currentValues, clickedValue];
currentProps.onValueChange?.(newValues);
}
};
let el = null;
queueMicrotask(() => {
el = groupRef.current;
el?.addEventListener('click', handleClick);
});
return { [Symbol.dispose]: () => el?.removeEventListener('click', handleClick) };
});
const { orientation = 'horizontal', disabled, color, size, style } = props;
const selectedValues = Array.isArray(props.value) ? props.value : props.value ? [props.value] : [];
const colors = color ? paletteFullColors[color] : defaultToggleColors;
useHostProps({
role: 'group',
'data-orientation': orientation,
'data-disabled': disabled ? '' : undefined,
style: {
'--toggle-color-main': colors.main,
...style,
},
});
// Sync data-selected, disabled, and data-size on child buttons.
// These can't be expressed in CSS because they depend on matching the
// group's value prop against each button's data-value attribute.
requestAnimationFrame(() => {
const buttons = Array.from(groupRef.current?.querySelectorAll('button[data-value]') ?? []);
buttons.forEach((btn) => {
const val = btn.getAttribute('data-value');
if (val && selectedValues.includes(val)) {
btn.setAttribute('data-selected', '');
}
else {
btn.removeAttribute('data-selected');
}
if (disabled) {
btn.setAttribute('disabled', '');
}
if (size && size !== 'medium') {
btn.setAttribute('data-size', size);
}
else if (size === 'medium') {
btn.removeAttribute('data-size');
}
});
});
return (createComponent("div", { ref: groupRef, style: { display: 'contents' } }, children));
},
});
const defaultSegmentedColors = {
main: cssVariableTheme.palette.primary.main,
mainContrast: cssVariableTheme.palette.primary.mainContrast,
};
export const SegmentedControl = Shade({
customElementName: 'shade-segmented-control',
css: {
display: 'inline-flex',
borderRadius: cssVariableTheme.shape.borderRadius.md,
background: cssVariableTheme.action.hoverBackground,
padding: '3px',
gap: '2px',
'& .segmented-option': {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: `${cssVariableTheme.spacing.sm} ${cssVariableTheme.spacing.lg}`,
borderRadius: cssVariableTheme.shape.borderRadius.sm,
border: 'none',
background: 'transparent',
color: cssVariableTheme.text.secondary,
fontSize: cssVariableTheme.typography.fontSize.md,
fontWeight: cssVariableTheme.typography.fontWeight.medium,
fontFamily: 'inherit',
letterSpacing: '0.3px',
cursor: 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
transition: buildTransition(['background', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default], ['color', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default], ['box-shadow', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default]),
},
'& .segmented-option:hover:not(:disabled):not([data-selected])': {
color: cssVariableTheme.text.primary,
background: 'color-mix(in srgb, var(--seg-color-main) 8%, transparent)',
},
'& .segmented-option[data-selected]': {
background: cssVariableTheme.background.paper,
color: 'var(--seg-color-main)',
fontWeight: cssVariableTheme.typography.fontWeight.semibold,
boxShadow: cssVariableTheme.shadows.sm,
},
'& .segmented-option:disabled': {
cursor: 'not-allowed',
opacity: '0.5',
},
'&[data-size="small"] .segmented-option': {
padding: `${cssVariableTheme.spacing.xs} ${cssVariableTheme.spacing.md}`,
fontSize: cssVariableTheme.typography.fontSize.sm,
},
'&[data-size="large"] .segmented-option': {
padding: `${cssVariableTheme.spacing.md} ${cssVariableTheme.spacing.xl}`,
fontSize: cssVariableTheme.typography.fontSize.lg,
},
},
render: ({ props, useHostProps }) => {
const { options, value, onValueChange, color, disabled, size, style } = props;
const colors = color
? { main: paletteFullColors[color].main, mainContrast: paletteFullColors[color].mainContrast }
: defaultSegmentedColors;
useHostProps({
role: 'radiogroup',
'data-size': size && size !== 'medium' ? size : undefined,
style: {
'--seg-color-main': colors.main,
'--seg-color-main-contrast': colors.mainContrast,
...style,
},
});
const buttons = options.map((option) => {
const isSelected = value === option.value;
const isDisabled = disabled || option.disabled;
return (createComponent("button", { type: "button", className: "segmented-option", disabled: isDisabled, role: "radio", "aria-checked": isSelected ? 'true' : 'false', "data-value": option.value, ...(isSelected ? { 'data-selected': '' } : {}), onclick: () => {
if (!isDisabled && value !== option.value) {
onValueChange?.(option.value);
}
} }, option.label));
});
return createComponent(createComponent, null, buttons);
},
});
//# sourceMappingURL=button-group.js.map