UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

315 lines 14.4 kB
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