@12-apps/ui 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/eslint.config.js +12 -0
  2. package/package.json +5 -4
  3. package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
  4. package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
  5. package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
  6. package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
  7. package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
  8. package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
  9. package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
  10. package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
  11. package/src/components/data-display/Carousel/Carousel.tsx +84 -732
  12. package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
  13. package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
  14. package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
  15. package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
  16. package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
  17. package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
  18. package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
  19. package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
  20. package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
  21. package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
  22. package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
  23. package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
  24. package/src/components/data-display/Sheet/Sheet.tsx +125 -948
  25. package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
  26. package/src/components/feedback/Modal/Modal.styles.ts +126 -0
  27. package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
  28. package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
  29. package/src/components/feedback/Modal/Modal.tsx +37 -242
  30. package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
  31. package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
  32. package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
  33. package/src/components/form/InputOTP/InputOTP.tsx +74 -221
  34. package/src/components/form/Label/Label.parts.tsx +75 -0
  35. package/src/components/form/Label/Label.styles.ts +206 -0
  36. package/src/components/form/Label/Label.test.stories.tsx +20 -14
  37. package/src/components/form/Label/Label.tsx +73 -325
  38. package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
  39. package/src/components/form/Menubar/Menubar.item.tsx +121 -0
  40. package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
  41. package/src/components/form/Menubar/Menubar.styles.ts +110 -0
  42. package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
  43. package/src/components/form/Menubar/Menubar.tsx +64 -347
  44. package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
  45. package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
  46. package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
  47. package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
  48. package/src/components/form/Slider/Slider.animations.ts +40 -0
  49. package/src/components/form/Slider/Slider.styles.ts +292 -0
  50. package/src/components/form/Slider/Slider.tsx +106 -357
  51. package/src/components/form/Switch/Switch.animations.ts +62 -0
  52. package/src/components/form/Switch/Switch.parts.tsx +213 -0
  53. package/src/components/form/Switch/Switch.styles.ts +355 -0
  54. package/src/components/form/Switch/Switch.tsx +87 -566
  55. package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
  56. package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
  57. package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
  58. package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
  59. package/src/components/typography/Heading/Heading.styles.ts +101 -0
  60. package/src/components/typography/Heading/Heading.tsx +48 -164
  61. package/src/components/typography/Text/Text.test.stories.tsx +27 -30
  62. package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
  63. package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
  64. package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
  65. package/src/utils/index.ts +1 -0
  66. package/src/utils/withDefaults.ts +20 -0
@@ -0,0 +1,41 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ import type { MenubarItem, MenubarProps } from './Menubar.types';
4
+
5
+ /**
6
+ * Which dropdown is open, and the buttons that opened them.
7
+ *
8
+ * The anchors are kept in a ref rather than state because closing needs to
9
+ * return focus to the button that opened the menu, and that lookup must not
10
+ * itself cause a render.
11
+ */
12
+ export const useMenubarMenus = (onClick?: MenubarProps['onClick']) => {
13
+ const [activeMenu, setActiveMenu] = useState<string | null>(null);
14
+ const menuRefs = useRef<{ [key: string]: HTMLElement | null }>({});
15
+
16
+ const handleMenuOpen = useCallback((itemId: string, anchorEl: HTMLElement) => {
17
+ setActiveMenu(itemId);
18
+ menuRefs.current[itemId] = anchorEl;
19
+ }, []);
20
+
21
+ const handleMenuClose = useCallback(() => {
22
+ setActiveMenu((previous) => {
23
+ // Return focus to the button that opened the menu.
24
+ if (previous && menuRefs.current[previous]) {
25
+ menuRefs.current[previous].focus();
26
+ }
27
+ return null;
28
+ });
29
+ }, []);
30
+
31
+ const handleItemClick = useCallback(
32
+ (item: MenubarItem) => {
33
+ item.action?.();
34
+ onClick?.(item);
35
+ handleMenuClose();
36
+ },
37
+ [onClick, handleMenuClose],
38
+ );
39
+
40
+ return { activeMenu, menuRefs, handleMenuOpen, handleMenuClose, handleItemClick };
41
+ };
@@ -0,0 +1,121 @@
1
+ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
2
+ import {
3
+ Box,
4
+ Button,
5
+ Chip,
6
+ Divider,
7
+ ListItemIcon,
8
+ ListItemText,
9
+ Menu,
10
+ MenuItem,
11
+ useTheme,
12
+ } from '@mui/material';
13
+ import React from 'react';
14
+
15
+ import { sizeStyles } from './Menubar.styles';
16
+ import type { MenubarItem, MenubarProps } from './Menubar.types';
17
+
18
+ export interface MenubarItemViewProps {
19
+ item: MenubarItem;
20
+ size: NonNullable<MenubarProps['size']>;
21
+ disabled: boolean;
22
+ isOpen: boolean;
23
+ anchorEl: HTMLElement | null;
24
+ onOpen: (itemId: string, anchorEl: HTMLElement) => void;
25
+ onActivate: (item: MenubarItem) => void;
26
+ onClose: () => void;
27
+ onFocus?: React.FocusEventHandler;
28
+ onBlur?: React.FocusEventHandler;
29
+ }
30
+
31
+ /** One row of an open menu: its icon, its label, and its shortcut hint. */
32
+ const DropdownItem: React.FC<{ child: MenubarItem; onActivate: (item: MenubarItem) => void }> = ({
33
+ child,
34
+ onActivate,
35
+ }) =>
36
+ child.divider ? (
37
+ <Divider />
38
+ ) : (
39
+ <MenuItem onClick={() => onActivate(child)} disabled={child.disabled}>
40
+ {child.icon && <ListItemIcon>{child.icon}</ListItemIcon>}
41
+ <ListItemText>{child.label}</ListItemText>
42
+ {child.shortcut && (
43
+ <Chip
44
+ label={child.shortcut}
45
+ size="small"
46
+ variant="outlined"
47
+ sx={{ ml: 2, height: 20, fontSize: '0.7rem' }}
48
+ />
49
+ )}
50
+ </MenuItem>
51
+ );
52
+
53
+ /**
54
+ * A top-level menubar entry. An entry with children opens its dropdown; one
55
+ * without acts immediately — a decision the click and keyboard paths both need,
56
+ * so it is made once here rather than in each handler.
57
+ */
58
+ export const MenubarItemView: React.FC<MenubarItemViewProps> = ({
59
+ item,
60
+ size,
61
+ disabled,
62
+ isOpen,
63
+ anchorEl,
64
+ onOpen,
65
+ onActivate,
66
+ onClose,
67
+ onFocus,
68
+ onBlur,
69
+ }) => {
70
+ const theme = useTheme();
71
+ const hasChildren = Boolean(item.children && item.children.length > 0);
72
+
73
+ const activate = (target: HTMLElement) => {
74
+ if (hasChildren) {
75
+ onOpen(item.id, target);
76
+ } else {
77
+ onActivate(item);
78
+ }
79
+ };
80
+
81
+ return (
82
+ <Box>
83
+ <Button
84
+ data-testid={`menubar-button-${item.id}`}
85
+ onClick={(e) => activate(e.currentTarget)}
86
+ onKeyDown={(e) => {
87
+ if (e.key === 'Enter' || e.key === ' ') {
88
+ e.preventDefault();
89
+ activate(e.currentTarget);
90
+ }
91
+ }}
92
+ // Keeping focus on mousedown is what lets handleMenuClose return it here.
93
+ onMouseDown={(e) => e.preventDefault()}
94
+ onFocus={onFocus}
95
+ onBlur={onBlur}
96
+ disabled={item.disabled || disabled}
97
+ startIcon={item.icon}
98
+ endIcon={hasChildren ? <KeyboardArrowDownIcon /> : null}
99
+ aria-haspopup={hasChildren ? 'menu' : undefined}
100
+ aria-expanded={hasChildren ? isOpen : undefined}
101
+ sx={{ color: 'inherit', textTransform: 'none', ...sizeStyles(theme, size) }}
102
+ >
103
+ {item.label}
104
+ </Button>
105
+
106
+ {hasChildren && (
107
+ <Menu
108
+ anchorEl={anchorEl}
109
+ open={isOpen}
110
+ onClose={onClose}
111
+ autoFocus
112
+ PaperProps={{ sx: { minWidth: 200, mt: 1 } }}
113
+ >
114
+ {item.children?.map((child) => (
115
+ <DropdownItem key={child.id} child={child} onActivate={onActivate} />
116
+ ))}
117
+ </Menu>
118
+ )}
119
+ </Box>
120
+ );
121
+ };
@@ -0,0 +1,25 @@
1
+ import { Divider } from '@mui/material';
2
+ import React from 'react';
3
+
4
+ import type { MenubarSeparatorProps } from './Menubar.types';
5
+
6
+ /**
7
+ * The rule between two entries. Its own orientation is the bar's turned ninety
8
+ * degrees: a horizontal bar is divided by vertical rules.
9
+ */
10
+ export const MenubarSeparator: React.FC<MenubarSeparatorProps> = ({
11
+ orientation = 'horizontal',
12
+ className,
13
+ style,
14
+ }) => (
15
+ <Divider
16
+ orientation={orientation === 'horizontal' ? 'vertical' : 'horizontal'}
17
+ flexItem
18
+ className={className}
19
+ sx={{
20
+ mx: orientation === 'horizontal' ? 1 : 0,
21
+ my: orientation === 'vertical' ? 1 : 0,
22
+ ...style,
23
+ }}
24
+ />
25
+ );
@@ -0,0 +1,110 @@
1
+ import { alpha } from '@mui/material';
2
+ import type { CSSObject, Theme } from '@mui/material';
3
+
4
+ import type { MenubarProps } from './Menubar.types';
5
+
6
+ type Size = NonNullable<MenubarProps['size']>;
7
+ type Variant = NonNullable<MenubarProps['variant']>;
8
+ type Color = NonNullable<MenubarProps['color']>;
9
+
10
+ const SIZES: Record<Size, (theme: Theme) => CSSObject> = {
11
+ xs: (theme) => ({ minHeight: 40, fontSize: '0.75rem', padding: theme.spacing(0.5, 1) }),
12
+ sm: (theme) => ({ minHeight: 48, fontSize: '0.875rem', padding: theme.spacing(0.75, 1.5) }),
13
+ md: (theme) => ({ minHeight: 56, fontSize: '1rem', padding: theme.spacing(1, 2) }),
14
+ lg: (theme) => ({ minHeight: 64, fontSize: '1.125rem', padding: theme.spacing(1.25, 2.5) }),
15
+ xl: (theme) => ({ minHeight: 72, fontSize: '1.25rem', padding: theme.spacing(1.5, 3) }),
16
+ };
17
+
18
+ export const sizeStyles = (theme: Theme, size: Size): CSSObject =>
19
+ (SIZES[size] ?? SIZES.md)(theme);
20
+
21
+ /** `default` is not a palette entry — it means the paper surface and its text. */
22
+ const accentOf = (theme: Theme, color: Color) =>
23
+ color === 'default' ? theme.palette.primary.main : theme.palette[color].main;
24
+
25
+ const colorStyles = (theme: Theme, color: Color, transparent: boolean): CSSObject => {
26
+ const surface =
27
+ color === 'default'
28
+ ? { backgroundColor: theme.palette.background.paper, color: theme.palette.text.primary }
29
+ : { backgroundColor: theme.palette[color].main, color: theme.palette[color].contrastText };
30
+
31
+ return transparent ? { ...surface, backgroundColor: 'transparent' } : surface;
32
+ };
33
+
34
+ interface MenubarStyleFlags {
35
+ variant: Variant;
36
+ size: Size;
37
+ color: Color;
38
+ glow: boolean;
39
+ pulse: boolean;
40
+ glass: boolean;
41
+ gradient: boolean;
42
+ blur: boolean;
43
+ transparent: boolean;
44
+ elevation: number;
45
+ }
46
+
47
+ /**
48
+ * Each variant's own additions to the shared bar. The `glass` and `gradient`
49
+ * arms deliberately do not take the blur spread: glass sets its own
50
+ * backdropFilter, and a blurred gradient loses the gradient.
51
+ */
52
+ const SURFACES: Record<Variant, (theme: Theme, flags: MenubarStyleFlags) => CSSObject> = {
53
+ glass: (theme, { glass }) => ({
54
+ backgroundColor: alpha(theme.palette.background.paper, glass ? 0.1 : 0.8),
55
+ backdropFilter: 'blur(20px)',
56
+ borderBottom: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
57
+ }),
58
+
59
+ gradient: (theme, { color, gradient }) => {
60
+ const key = color === 'default' ? 'primary' : color;
61
+ return {
62
+ background: gradient
63
+ ? `linear-gradient(135deg, ${theme.palette[key].light}, ${theme.palette[key].dark})`
64
+ : theme.palette[key].main,
65
+ color: theme.palette[key].contrastText,
66
+ };
67
+ },
68
+
69
+ elevated: (theme, { blur, elevation }) => ({
70
+ ...(blur && { backdropFilter: 'blur(10px)' }),
71
+ boxShadow: theme.shadows[elevation],
72
+ }),
73
+
74
+ minimal: (_theme, { blur }) => ({
75
+ ...(blur && { backdropFilter: 'blur(10px)' }),
76
+ boxShadow: 'none',
77
+ borderBottom: 'none',
78
+ }),
79
+
80
+ bordered: (theme, { blur, color }) => ({
81
+ ...(blur && { backdropFilter: 'blur(10px)' }),
82
+ borderBottom: `2px solid ${
83
+ color === 'default' ? theme.palette.divider : theme.palette[color].main
84
+ }`,
85
+ }),
86
+
87
+ default: (_theme, { blur }) => ({ ...(blur && { backdropFilter: 'blur(10px)' }) }),
88
+ };
89
+
90
+ export const barStyles = (theme: Theme, flags: MenubarStyleFlags): CSSObject => {
91
+ const { variant, size, color, glow, pulse, transparent } = flags;
92
+
93
+ return {
94
+ transition: theme.transitions.create(['all'], {
95
+ duration: theme.transitions.duration.standard,
96
+ }),
97
+ ...sizeStyles(theme, size),
98
+ ...colorStyles(theme, color, transparent),
99
+ ...(glow && { boxShadow: `0 0 20px ${alpha(accentOf(theme, color), 0.4)}` }),
100
+ ...(pulse && {
101
+ animation: 'pulse 2s infinite',
102
+ '@keyframes pulse': {
103
+ '0%': { opacity: 1 },
104
+ '50%': { opacity: 0.8 },
105
+ '100%': { opacity: 1 },
106
+ },
107
+ }),
108
+ ...(SURFACES[variant] ?? SURFACES.default)(theme, flags),
109
+ };
110
+ };
@@ -0,0 +1,103 @@
1
+ import { Box, CircularProgress, Toolbar } from '@mui/material';
2
+ import type { CSSObject } from '@mui/material';
3
+ import React from 'react';
4
+
5
+ import { MenubarItemView } from './Menubar.item';
6
+ import { MenubarSeparator } from './Menubar.separator';
7
+ import type { MenubarItem, MenubarProps } from './Menubar.types';
8
+
9
+ export interface MenubarToolbarProps {
10
+ barSx: CSSObject;
11
+ items: MenubarItem[];
12
+ size: NonNullable<MenubarProps['size']>;
13
+ orientation: NonNullable<MenubarProps['orientation']>;
14
+ disabled: boolean;
15
+ loading: boolean;
16
+ logo?: React.ReactNode;
17
+ endContent?: React.ReactNode;
18
+ fullWidth: boolean;
19
+ activeMenu: string | null;
20
+ menuRefs: React.MutableRefObject<{ [key: string]: HTMLElement | null }>;
21
+ onOpen: (itemId: string, anchorEl: HTMLElement) => void;
22
+ onActivate: (item: MenubarItem) => void;
23
+ onClose: () => void;
24
+ onFocus?: React.FocusEventHandler;
25
+ onBlur?: React.FocusEventHandler;
26
+ }
27
+
28
+ /**
29
+ * The bar's contents: an optional logo, the entries themselves, and whatever the
30
+ * caller hangs off the end. Shared by both orientations — only the element
31
+ * wrapping it differs.
32
+ */
33
+ export const MenubarToolbar: React.FC<MenubarToolbarProps> = ({
34
+ barSx,
35
+ items,
36
+ size,
37
+ orientation,
38
+ disabled,
39
+ loading,
40
+ logo,
41
+ endContent,
42
+ fullWidth,
43
+ activeMenu,
44
+ menuRefs,
45
+ onOpen,
46
+ onActivate,
47
+ onClose,
48
+ onFocus,
49
+ onBlur,
50
+ }) => {
51
+ const isVertical = orientation === 'vertical';
52
+
53
+ return (
54
+ <Toolbar
55
+ role="toolbar"
56
+ aria-label="Main navigation"
57
+ sx={{
58
+ ...barSx,
59
+ width: fullWidth ? '100%' : 'auto',
60
+ flexDirection: isVertical ? 'column' : 'row',
61
+ alignItems: isVertical ? 'flex-start' : 'center',
62
+ gap: 1,
63
+ }}
64
+ >
65
+ {logo && <Box sx={{ mr: 2, display: 'flex', alignItems: 'center' }}>{logo}</Box>}
66
+
67
+ {loading ? (
68
+ <CircularProgress size={20} color="inherit" />
69
+ ) : (
70
+ <Box
71
+ sx={{
72
+ display: 'flex',
73
+ flexDirection: isVertical ? 'column' : 'row',
74
+ gap: isVertical ? 0.5 : 1,
75
+ flex: 1,
76
+ }}
77
+ >
78
+ {items.map((item) =>
79
+ item.divider ? (
80
+ <MenubarSeparator key={item.id} orientation={orientation} />
81
+ ) : (
82
+ <MenubarItemView
83
+ key={item.id}
84
+ item={item}
85
+ size={size}
86
+ disabled={disabled}
87
+ isOpen={activeMenu === item.id}
88
+ anchorEl={menuRefs.current[item.id] ?? null}
89
+ onOpen={onOpen}
90
+ onActivate={onActivate}
91
+ onClose={onClose}
92
+ onFocus={onFocus}
93
+ onBlur={onBlur}
94
+ />
95
+ ),
96
+ )}
97
+ </Box>
98
+ )}
99
+
100
+ {endContent && <Box sx={{ ml: 'auto' }}>{endContent}</Box>}
101
+ </Toolbar>
102
+ );
103
+ };