@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
202 lines • 10.6 kB
JavaScript
import { LocationService, Shade, createComponent, transitionedValue } from '@furystack/shades';
import { buildTransition, cssVariableTheme } from '../services/css-variable-theme.js';
import { close } from './icons/icon-definitions.js';
import { Icon } from './icons/icon.js';
const TabHeader = Shade({
customElementName: 'shade-tab-header',
css: {
padding: `${cssVariableTheme.spacing.md} 40px`,
cursor: 'pointer',
transition: buildTransition(['box-shadow', cssVariableTheme.transitions.duration.slow, 'ease'], ['background', cssVariableTheme.transitions.duration.slow, 'ease'], ['color', cssVariableTheme.transitions.duration.slow, 'ease'], ['font-weight', cssVariableTheme.transitions.duration.slow, 'ease']),
fontWeight: 'inherit',
background: cssVariableTheme.background.default,
color: cssVariableTheme.text.secondary,
boxShadow: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: cssVariableTheme.spacing.sm,
textDecoration: 'none',
'&[data-active]': {
fontWeight: 'bolder',
background: cssVariableTheme.background.paper,
color: cssVariableTheme.text.primary,
boxShadow: `inset 0 -2px 0 ${cssVariableTheme.palette.primary.main}`,
},
},
elementBase: HTMLAnchorElement,
elementBaseName: 'a',
render: ({ children, injector, props, useObservable, useHostProps }) => {
const locationService = injector.get(LocationService);
const [hash] = useObservable('updateLocation', locationService.onLocationHashChanged);
const isActive = hash === props.hash;
useHostProps({
href: `#${props.hash}`,
...(isActive ? { 'data-active': '' } : {}),
});
return createComponent(createComponent, null, children);
},
});
export const Tabs = Shade({
customElementName: 'shade-tabs',
css: {
fontFamily: cssVariableTheme.typography.fontFamily,
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
'& .shade-tabs-header-container': {
display: 'inline-flex',
borderRadius: `${cssVariableTheme.shape.borderRadius.md} ${cssVariableTheme.shape.borderRadius.md} 0 0`,
overflow: 'hidden',
flexShrink: '0',
},
// Controlled mode tab button
'& .shade-tab-btn': {
padding: `${cssVariableTheme.spacing.md} 40px`,
cursor: 'pointer',
transition: buildTransition(['box-shadow', cssVariableTheme.transitions.duration.slow, 'ease'], ['background', cssVariableTheme.transitions.duration.slow, 'ease'], ['color', cssVariableTheme.transitions.duration.slow, 'ease'], ['font-weight', cssVariableTheme.transitions.duration.slow, 'ease']),
fontWeight: 'inherit',
background: cssVariableTheme.background.default,
color: cssVariableTheme.text.secondary,
boxShadow: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: cssVariableTheme.spacing.sm,
border: 'none',
font: 'inherit',
},
'& .shade-tab-btn.active': {
fontWeight: 'bolder',
background: cssVariableTheme.background.paper,
color: cssVariableTheme.text.primary,
boxShadow: `inset 0 -2px 0 ${cssVariableTheme.palette.primary.main}`,
},
'& .shade-tab-btn:focus-visible': {
outline: cssVariableTheme.action.focusOutline,
outlineOffset: '-2px',
},
// Close button (span with role="button" via event delegation)
'& .shade-tab-close': {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
borderRadius: cssVariableTheme.shape.borderRadius.xs,
opacity: '0.5',
fontSize: '12px',
lineHeight: '1',
transition: buildTransition(['opacity', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default], ['background', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default]),
},
'& .shade-tab-close:hover': {
opacity: '1',
background: cssVariableTheme.action.hoverBackground,
},
// Add tab button
'& .shade-tab-add': {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: `${cssVariableTheme.spacing.sm} ${cssVariableTheme.spacing.md}`,
border: 'none',
background: 'transparent',
color: cssVariableTheme.text.secondary,
cursor: 'pointer',
fontSize: '18px',
lineHeight: '1',
transition: buildTransition(['color', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default], ['background', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default]),
},
'& .shade-tab-add:hover': {
color: cssVariableTheme.text.primary,
background: cssVariableTheme.action.hoverBackground,
},
// --- Vertical orientation ---
'&[data-orientation="vertical"]': {
flexDirection: 'row',
},
'&[data-orientation="vertical"] .shade-tabs-header-container': {
flexDirection: 'column',
borderRadius: `${cssVariableTheme.shape.borderRadius.md} 0 0 ${cssVariableTheme.shape.borderRadius.md}`,
},
'&[data-orientation="vertical"] a[is="shade-tab-header"][data-active], &[data-orientation="vertical"] .shade-tab-btn.active': {
boxShadow: `inset -2px 0 0 ${cssVariableTheme.palette.primary.main}`,
},
// --- Card type ---
'&[data-type="card"] .shade-tabs-header-container': {
gap: '2px',
borderRadius: '0',
overflow: 'visible',
borderBottom: `1px solid ${cssVariableTheme.action.subtleBorder}`,
},
'&[data-type="card"] a[is="shade-tab-header"], &[data-type="card"] .shade-tab-btn': {
borderRadius: `${cssVariableTheme.shape.borderRadius.md} ${cssVariableTheme.shape.borderRadius.md} 0 0`,
border: '1px solid transparent',
borderBottom: 'none',
marginBottom: '-1px',
background: 'transparent',
},
'&[data-type="card"] a[is="shade-tab-header"][data-active], &[data-type="card"] .shade-tab-btn.active': {
boxShadow: 'none',
background: cssVariableTheme.background.paper,
border: `1px solid ${cssVariableTheme.action.subtleBorder}`,
borderBottom: `1px solid ${cssVariableTheme.background.paper}`,
},
// --- Card type + vertical ---
'&[data-type="card"][data-orientation="vertical"] .shade-tabs-header-container': {
borderBottom: 'none',
borderRight: `1px solid ${cssVariableTheme.action.subtleBorder}`,
},
'&[data-type="card"][data-orientation="vertical"] a[is="shade-tab-header"], &[data-type="card"][data-orientation="vertical"] .shade-tab-btn': {
borderRadius: `${cssVariableTheme.shape.borderRadius.md} 0 0 ${cssVariableTheme.shape.borderRadius.md}`,
border: '1px solid transparent',
borderRight: 'none',
marginRight: '-1px',
marginBottom: '0',
},
'&[data-type="card"][data-orientation="vertical"] a[is="shade-tab-header"][data-active], &[data-type="card"][data-orientation="vertical"] .shade-tab-btn.active': {
boxShadow: 'none',
background: cssVariableTheme.background.paper,
border: `1px solid ${cssVariableTheme.action.subtleBorder}`,
borderRight: `1px solid ${cssVariableTheme.background.paper}`,
},
},
render: ({ props, useObservable, injector, useHostProps, useState }) => {
useHostProps({
...(props.containerStyle ? { style: props.containerStyle } : {}),
...(props.orientation === 'vertical' ? { 'data-orientation': 'vertical' } : {}),
...(props.type === 'card' ? { 'data-type': 'card' } : {}),
});
const isControlled = props.activeKey !== undefined;
const [hash] = useObservable('updateLocation', injector.get(LocationService).onLocationHashChanged);
const activeKey = isControlled ? props.activeKey : hash.replace('#', '');
const displayedKey = transitionedValue(useState, 'displayedKey', activeKey, props.viewTransition);
const displayedTab = props.tabs.find((t) => t.hash === displayedKey);
const handleTabClick = (e, tab, index) => {
const target = e.target;
if (target.closest('.shade-tab-close')) {
e.stopPropagation();
e.preventDefault();
props.onClose?.(tab.hash);
return;
}
props.onTabChange?.(tab.hash);
props.onChange?.(index);
};
return (createComponent(createComponent, null,
createComponent("div", { className: "shade-tabs-header-container", role: "tablist" },
props.tabs.map((tab, index) => {
const isActive = tab.hash === activeKey;
const hasCloseButton = tab.closable && props.onClose;
return isControlled ? (createComponent("button", { className: `shade-tab-btn${isActive ? ' active' : ''}`, role: "tab", "aria-selected": String(isActive), tabIndex: isActive ? 0 : -1, onclick: (e) => handleTabClick(e, tab, index) },
tab.header,
hasCloseButton ? (createComponent("span", { className: "shade-tab-close" },
createComponent(Icon, { icon: close, size: 12 }))) : null)) : (createComponent(TabHeader, { hash: tab.hash, onclick: (e) => handleTabClick(e, tab, index) },
tab.header,
hasCloseButton ? (createComponent("span", { className: "shade-tab-close" },
createComponent(Icon, { icon: close, size: 12 }))) : null));
}),
props.onAdd ? (createComponent("button", { className: "shade-tab-add", "aria-label": "Add tab", onclick: () => props.onAdd() }, "+")) : null),
displayedTab?.component));
},
});
//# sourceMappingURL=tabs.js.map