mtrl 0.2.4 → 0.2.6

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 (76) hide show
  1. package/package.json +6 -3
  2. package/src/components/badge/_styles.scss +9 -9
  3. package/src/components/button/_styles.scss +0 -56
  4. package/src/components/button/button.ts +0 -2
  5. package/src/components/button/constants.ts +0 -6
  6. package/src/components/button/index.ts +2 -2
  7. package/src/components/button/types.ts +1 -7
  8. package/src/components/card/_styles.scss +67 -25
  9. package/src/components/card/api.ts +54 -3
  10. package/src/components/card/card.ts +33 -2
  11. package/src/components/card/config.ts +143 -21
  12. package/src/components/card/constants.ts +20 -19
  13. package/src/components/card/content.ts +299 -2
  14. package/src/components/card/features.ts +155 -4
  15. package/src/components/card/index.ts +31 -9
  16. package/src/components/card/types.ts +138 -15
  17. package/src/components/chip/chip.ts +1 -9
  18. package/src/components/chip/constants.ts +0 -10
  19. package/src/components/chip/index.ts +1 -1
  20. package/src/components/chip/types.ts +1 -4
  21. package/src/components/progress/_styles.scss +0 -65
  22. package/src/components/progress/config.ts +1 -2
  23. package/src/components/progress/constants.ts +0 -14
  24. package/src/components/progress/index.ts +1 -1
  25. package/src/components/progress/progress.ts +1 -4
  26. package/src/components/progress/types.ts +1 -4
  27. package/src/components/radios/_styles.scss +0 -45
  28. package/src/components/radios/api.ts +85 -60
  29. package/src/components/radios/config.ts +1 -2
  30. package/src/components/radios/constants.ts +0 -9
  31. package/src/components/radios/index.ts +1 -1
  32. package/src/components/radios/radio.ts +34 -11
  33. package/src/components/radios/radios.ts +2 -1
  34. package/src/components/radios/types.ts +1 -7
  35. package/src/components/slider/_styles.scss +193 -281
  36. package/src/components/slider/accessibility.md +59 -0
  37. package/src/components/slider/api.ts +36 -101
  38. package/src/components/slider/config.ts +29 -78
  39. package/src/components/slider/constants.ts +12 -8
  40. package/src/components/slider/features/appearance.ts +1 -47
  41. package/src/components/slider/features/disabled.ts +41 -16
  42. package/src/components/slider/features/interactions.ts +166 -26
  43. package/src/components/slider/features/keyboard.ts +125 -6
  44. package/src/components/slider/features/structure.ts +182 -195
  45. package/src/components/slider/features/ui.ts +234 -303
  46. package/src/components/slider/index.ts +11 -1
  47. package/src/components/slider/slider.ts +1 -1
  48. package/src/components/slider/types.ts +10 -25
  49. package/src/components/tabs/_styles.scss +285 -155
  50. package/src/components/tabs/api.ts +178 -400
  51. package/src/components/tabs/config.ts +46 -52
  52. package/src/components/tabs/constants.ts +85 -8
  53. package/src/components/tabs/features.ts +401 -0
  54. package/src/components/tabs/index.ts +60 -3
  55. package/src/components/tabs/indicator.ts +225 -0
  56. package/src/components/tabs/responsive.ts +144 -0
  57. package/src/components/tabs/scroll-indicators.ts +149 -0
  58. package/src/components/tabs/state.ts +186 -0
  59. package/src/components/tabs/tab-api.ts +258 -0
  60. package/src/components/tabs/tab.ts +255 -0
  61. package/src/components/tabs/tabs.ts +50 -31
  62. package/src/components/tabs/types.ts +324 -128
  63. package/src/components/tabs/utils.ts +107 -0
  64. package/src/components/textfield/_styles.scss +0 -98
  65. package/src/components/textfield/config.ts +2 -3
  66. package/src/components/textfield/constants.ts +0 -14
  67. package/src/components/textfield/index.ts +2 -2
  68. package/src/components/textfield/textfield.ts +0 -2
  69. package/src/components/textfield/types.ts +1 -4
  70. package/src/core/compose/component.ts +1 -1
  71. package/src/core/compose/features/badge.ts +79 -0
  72. package/src/core/compose/features/index.ts +3 -1
  73. package/src/styles/abstract/_theme.scss +106 -2
  74. package/src/components/card/actions.ts +0 -48
  75. package/src/components/card/header.ts +0 -88
  76. package/src/components/card/media.ts +0 -52
@@ -0,0 +1,225 @@
1
+ // src/components/tabs/indicator.ts
2
+ import { TabComponent } from './types';
3
+
4
+ /**
5
+ * Configuration for tab indicator
6
+ */
7
+ export interface TabIndicatorConfig {
8
+ /** Height of the indicator in pixels */
9
+ height?: number;
10
+ /** Width strategy - fixed size or dynamic based on tab width */
11
+ widthStrategy?: 'fixed' | 'dynamic' | 'content';
12
+ /** Fixed width in pixels when using fixed strategy */
13
+ fixedWidth?: number;
14
+ /** Animation duration in milliseconds */
15
+ animationDuration?: number;
16
+ /** Animation timing function */
17
+ animationTiming?: string;
18
+ /** Whether to show the indicator */
19
+ visible?: boolean;
20
+ /** CSS class prefix */
21
+ prefix?: string;
22
+ /** Custom color for the indicator */
23
+ color?: string;
24
+ }
25
+
26
+ /**
27
+ * Tab indicator API
28
+ */
29
+ export interface TabIndicator {
30
+ /** The indicator DOM element */
31
+ element: HTMLElement;
32
+ /** Move the indicator to a specific tab */
33
+ moveToTab: (tab: TabComponent, immediate?: boolean) => void;
34
+ /** Show the indicator */
35
+ show: () => void;
36
+ /** Hide the indicator */
37
+ hide: () => void;
38
+ /** Set indicator color */
39
+ setColor: (color: string) => void;
40
+ /** Update indicator position (e.g. after resize) */
41
+ update: () => void;
42
+ /** Destroy the indicator and clean up */
43
+ destroy: () => void;
44
+ }
45
+
46
+ /**
47
+ * Default configuration for tab indicator
48
+ */
49
+ const DEFAULT_CONFIG: TabIndicatorConfig = {
50
+ widthStrategy: 'fixed',
51
+ fixedWidth: 40,
52
+ animationDuration: 250,
53
+ animationTiming: 'cubic-bezier(0.4, 0, 0.2, 1)',
54
+ visible: true,
55
+ prefix: 'mtrl'
56
+ };
57
+
58
+ /**
59
+ * Creates a tab indicator component
60
+ * @param config - Indicator configuration
61
+ * @returns Tab indicator instance
62
+ */
63
+ export const createTabIndicator = (config: TabIndicatorConfig = {}): TabIndicator => {
64
+ // Merge with default config
65
+ const mergedConfig = { ...DEFAULT_CONFIG, ...config };
66
+ const prefix = mergedConfig.prefix || 'mtrl';
67
+
68
+ // Create indicator element
69
+ const element = document.createElement('div');
70
+ element.className = `${prefix}-tabs-indicator`;
71
+ element.style.transition = `transform ${mergedConfig.animationDuration}ms ${mergedConfig.animationTiming},
72
+ width ${mergedConfig.animationDuration}ms ${mergedConfig.animationTiming}`;
73
+ element.style.width = `${mergedConfig.fixedWidth}px`; // Set initial width
74
+
75
+ // Set initial visibility
76
+ if (!mergedConfig.visible) {
77
+ element.style.opacity = '0';
78
+ }
79
+
80
+ // Track current tab to be able to update on resize
81
+ let currentTab: TabComponent | null = null;
82
+
83
+ /**
84
+ * Calculates indicator width based on strategy
85
+ * @param tab - Target tab
86
+ * @returns Width in pixels
87
+ */
88
+ const calculateWidth = (tab: TabComponent): number => {
89
+ switch (mergedConfig.widthStrategy) {
90
+ case 'dynamic':
91
+ return Math.max(tab.element.offsetWidth / 2, 30);
92
+ case 'content':
93
+ // Try to match content width
94
+ const text = tab.element.querySelector(`.${prefix}-button-text`);
95
+ if (text) {
96
+ return Math.max(text.clientWidth, 30);
97
+ }
98
+ return mergedConfig.fixedWidth || 40;
99
+ case 'fixed':
100
+ default:
101
+ return mergedConfig.fixedWidth || 40;
102
+ }
103
+ };
104
+
105
+ /**
106
+ * Gets the direct DOM position for a tab element
107
+ * @param tabElement - The tab element
108
+ * @returns {Object} Position information
109
+ */
110
+ const getTabPosition = (tabElement: HTMLElement): { left: number, width: number } => {
111
+ // Find the scroll container (should be the parent of the tab)
112
+ const scrollContainer = tabElement.parentElement;
113
+ if (!scrollContainer) {
114
+ console.error('Tab has no parent element, cannot position indicator');
115
+ return { left: 0, width: tabElement.offsetWidth };
116
+ }
117
+
118
+ // Get positions using getBoundingClientRect for most accurate values
119
+ const tabRect = tabElement.getBoundingClientRect();
120
+ const containerRect = scrollContainer.getBoundingClientRect();
121
+
122
+ // Calculate position relative to scroll container
123
+ return {
124
+ left: tabRect.left - containerRect.left,
125
+ width: tabRect.width
126
+ };
127
+ };
128
+
129
+ /**
130
+ * Moves indicator to specified tab
131
+ * @param tab - Target tab
132
+ * @param immediate - Whether to skip animation
133
+ */
134
+ const moveToTab = (tab: TabComponent, immediate: boolean = false): void => {
135
+ if (!tab || !tab.element) {
136
+ console.error('Invalid tab or tab has no element');
137
+ return;
138
+ }
139
+
140
+ // Store current tab for later updates
141
+ currentTab = tab;
142
+
143
+ // Calculate indicator width
144
+ const width = calculateWidth(tab);
145
+
146
+ // Get tab position directly from DOM
147
+ const { left, width: tabWidth } = getTabPosition(tab.element);
148
+
149
+ // Calculate position to center indicator on tab
150
+ const tabCenter = left + (tabWidth / 2);
151
+ const indicatorLeft = tabCenter - (width / 2);
152
+
153
+ // Apply position immediately if requested
154
+ if (immediate) {
155
+ element.style.transition = 'none';
156
+
157
+ // Force reflow to ensure transition is skipped
158
+ element.offsetHeight; // eslint-disable-line no-unused-expressions
159
+ }
160
+
161
+ // Update position and width
162
+ element.style.width = `${width}px`;
163
+ element.style.transform = `translateX(${indicatorLeft}px)`;
164
+
165
+ // Restore transition after immediate update
166
+ if (immediate) {
167
+ // Need to use timeout to ensure browser processes the style change
168
+ setTimeout(() => {
169
+ element.style.transition = `transform ${mergedConfig.animationDuration}ms ${mergedConfig.animationTiming},
170
+ width ${mergedConfig.animationDuration}ms ${mergedConfig.animationTiming}`;
171
+ }, 10);
172
+ }
173
+ };
174
+
175
+ /**
176
+ * Updates indicator position (e.g. after resize)
177
+ */
178
+ const update = (): void => {
179
+ if (currentTab) {
180
+ moveToTab(currentTab, true);
181
+ }
182
+ };
183
+
184
+ /**
185
+ * Shows the indicator
186
+ */
187
+ const show = (): void => {
188
+ element.style.opacity = '1';
189
+ };
190
+
191
+ /**
192
+ * Hides the indicator
193
+ */
194
+ const hide = (): void => {
195
+ element.style.opacity = '0';
196
+ };
197
+
198
+ /**
199
+ * Sets indicator color
200
+ * @param color - CSS color value
201
+ */
202
+ const setColor = (color: string): void => {
203
+ element.style.backgroundColor = color;
204
+ };
205
+
206
+ /**
207
+ * Cleans up and destroys the indicator
208
+ */
209
+ const destroy = (): void => {
210
+ if (element.parentNode) {
211
+ element.parentNode.removeChild(element);
212
+ }
213
+ currentTab = null;
214
+ };
215
+
216
+ return {
217
+ element,
218
+ moveToTab,
219
+ show,
220
+ hide,
221
+ setColor,
222
+ update,
223
+ destroy
224
+ };
225
+ };
@@ -0,0 +1,144 @@
1
+ // src/components/tabs/responsive.ts
2
+ import { TabsComponent, TabComponent } from './types';
3
+
4
+ /**
5
+ * Breakpoints for responsive behavior
6
+ */
7
+ export const RESPONSIVE_BREAKPOINTS = {
8
+ /** Small screens (mobile) */
9
+ SMALL: 600,
10
+ /** Medium screens (tablet) */
11
+ MEDIUM: 904,
12
+ /** Large screens (desktop) */
13
+ LARGE: 1240
14
+ };
15
+
16
+ /**
17
+ * Configuration for responsive behavior
18
+ */
19
+ export interface ResponsiveConfig {
20
+ /** Whether to enable responsive behavior */
21
+ responsive?: boolean;
22
+ /** Options for small screens */
23
+ smallScreen?: {
24
+ /** Layout to use on small screens */
25
+ layout?: 'icon-only' | 'text-only' | 'icon-and-text';
26
+ /** Maximum tabs to show before scrolling */
27
+ maxVisibleTabs?: number;
28
+ };
29
+ /** Custom breakpoint values */
30
+ breakpoints?: {
31
+ small?: number;
32
+ medium?: number;
33
+ large?: number;
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Enhances tabs with responsive behavior
39
+ * @param tabs - The tabs component to enhance
40
+ * @param config - Responsive configuration
41
+ */
42
+ export const setupResponsiveBehavior = (
43
+ tabs: TabsComponent,
44
+ config: ResponsiveConfig = {}
45
+ ): void => {
46
+ if (config.responsive === false) return;
47
+
48
+ // Merge custom breakpoints with defaults
49
+ const breakpoints = {
50
+ small: config.breakpoints?.small || RESPONSIVE_BREAKPOINTS.SMALL,
51
+ medium: config.breakpoints?.medium || RESPONSIVE_BREAKPOINTS.MEDIUM,
52
+ large: config.breakpoints?.large || RESPONSIVE_BREAKPOINTS.LARGE
53
+ };
54
+
55
+ // Default small screen configuration
56
+ const smallScreen = {
57
+ layout: config.smallScreen?.layout || 'icon-only',
58
+ maxVisibleTabs: config.smallScreen?.maxVisibleTabs || 4
59
+ };
60
+
61
+ // Store original tab layouts to restore later
62
+ const originalLayouts = new Map<TabComponent, string>();
63
+
64
+ // Get all tabs
65
+ const allTabs = tabs.getTabs();
66
+
67
+ // Save original layouts
68
+ allTabs.forEach(tab => {
69
+ // Determine current layout
70
+ let layout = 'text-only';
71
+ if (tab.getIcon() && tab.getText()) {
72
+ layout = 'icon-and-text';
73
+ } else if (tab.getIcon()) {
74
+ layout = 'icon-only';
75
+ }
76
+
77
+ originalLayouts.set(tab, layout);
78
+ });
79
+
80
+ /**
81
+ * Update tabs layout based on screen size
82
+ */
83
+ const updateLayout = (): void => {
84
+ const width = window.innerWidth;
85
+
86
+ if (width < breakpoints.small) {
87
+ // Small screen behavior
88
+ allTabs.forEach(tab => {
89
+ // Skip if tab has no icon but we want icon-only
90
+ if (smallScreen.layout === 'icon-only' && !tab.getIcon()) {
91
+ return;
92
+ }
93
+
94
+ // Apply layout according to small screen config
95
+ if (smallScreen.layout === 'icon-only' && tab.getIcon()) {
96
+ // Keep text for accessibility but visually show only icon
97
+ tab.element.classList.remove(`${tab.getClass('tab')}--icon-and-text`);
98
+ tab.element.classList.remove(`${tab.getClass('tab')}--text-only`);
99
+ tab.element.classList.add(`${tab.getClass('tab')}--icon-only`);
100
+ } else if (smallScreen.layout === 'text-only') {
101
+ tab.element.classList.remove(`${tab.getClass('tab')}--icon-and-text`);
102
+ tab.element.classList.remove(`${tab.getClass('tab')}--icon-only`);
103
+ tab.element.classList.add(`${tab.getClass('tab')}--text-only`);
104
+ }
105
+ });
106
+
107
+ // Add responsive class
108
+ tabs.element.classList.add(`${tabs.getClass('tabs')}--responsive-small`);
109
+ } else {
110
+ // Restore original layouts for medium and large screens
111
+ allTabs.forEach(tab => {
112
+ const originalLayout = originalLayouts.get(tab) || 'text-only';
113
+
114
+ tab.element.classList.remove(`${tab.getClass('tab')}--icon-only`);
115
+ tab.element.classList.remove(`${tab.getClass('tab')}--text-only`);
116
+ tab.element.classList.remove(`${tab.getClass('tab')}--icon-and-text`);
117
+
118
+ tab.element.classList.add(`${tab.getClass('tab')}--${originalLayout}`);
119
+ });
120
+
121
+ // Remove responsive class
122
+ tabs.element.classList.remove(`${tabs.getClass('tabs')}--responsive-small`);
123
+ }
124
+ };
125
+
126
+ // Initial layout update
127
+ updateLayout();
128
+
129
+ // Set up resize listener
130
+ const resizeObserver = new ResizeObserver(updateLayout);
131
+ resizeObserver.observe(document.body);
132
+
133
+ // Store the observer on the component for cleanup
134
+ (tabs as any)._resizeObserver = resizeObserver;
135
+
136
+ // Enhance destroy method to clean up observer
137
+ const originalDestroy = tabs.destroy;
138
+ tabs.destroy = function() {
139
+ if ((this as any)._resizeObserver) {
140
+ (this as any)._resizeObserver.disconnect();
141
+ }
142
+ originalDestroy.call(this);
143
+ };
144
+ };
@@ -0,0 +1,149 @@
1
+ // src/components/tabs/scroll-indicators.ts
2
+ import { TabsComponent } from './types';
3
+
4
+ /**
5
+ * Configuration for scroll indicators
6
+ */
7
+ export interface ScrollIndicatorConfig {
8
+ /** Whether to show scroll indicators */
9
+ enabled?: boolean;
10
+ /** Whether to add scroll buttons */
11
+ showButtons?: boolean;
12
+ /** Scroll indicator appearance */
13
+ appearance?: 'fade' | 'shadow';
14
+ }
15
+
16
+ /**
17
+ * Adds scroll indicators to a tabs component
18
+ * @param tabs - Tabs component to enhance
19
+ * @param config - Scroll indicator configuration
20
+ */
21
+ export const addScrollIndicators = (
22
+ tabs: TabsComponent,
23
+ config: ScrollIndicatorConfig = {}
24
+ ): void => {
25
+ const {
26
+ enabled = true,
27
+ showButtons = false,
28
+ appearance = 'fade'
29
+ } = config;
30
+
31
+ if (!enabled) return;
32
+
33
+ // Find scroll container
34
+ const scrollContainer = tabs.element.querySelector(`.${tabs.getClass('tabs')}-scroll`);
35
+ if (!scrollContainer) return;
36
+
37
+ // Add indicator elements
38
+ const leftIndicator = document.createElement('div');
39
+ leftIndicator.className = `${tabs.getClass('tabs')}-scroll-indicator ${tabs.getClass('tabs')}-scroll-indicator--left`;
40
+ leftIndicator.classList.add(`${tabs.getClass('tabs')}-scroll-indicator--${appearance}`);
41
+
42
+ const rightIndicator = document.createElement('div');
43
+ rightIndicator.className = `${tabs.getClass('tabs')}-scroll-indicator ${tabs.getClass('tabs')}-scroll-indicator--right`;
44
+ rightIndicator.classList.add(`${tabs.getClass('tabs')}-scroll-indicator--${appearance}`);
45
+
46
+ tabs.element.appendChild(leftIndicator);
47
+ tabs.element.appendChild(rightIndicator);
48
+
49
+ // Add buttons if requested
50
+ if (showButtons) {
51
+ const leftButton = document.createElement('button');
52
+ leftButton.className = `${tabs.getClass('tabs')}-scroll-button ${tabs.getClass('tabs')}-scroll-button--left`;
53
+ leftButton.setAttribute('aria-label', 'Scroll tabs left');
54
+ leftButton.innerHTML = '<svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>';
55
+
56
+ const rightButton = document.createElement('button');
57
+ rightButton.className = `${tabs.getClass('tabs')}-scroll-button ${tabs.getClass('tabs')}-scroll-button--right`;
58
+ rightButton.setAttribute('aria-label', 'Scroll tabs right');
59
+ rightButton.innerHTML = '<svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>';
60
+
61
+ tabs.element.appendChild(leftButton);
62
+ tabs.element.appendChild(rightButton);
63
+
64
+ // Add button click handlers
65
+ leftButton.addEventListener('click', () => {
66
+ scrollContainer.scrollBy({
67
+ left: -100,
68
+ behavior: 'smooth'
69
+ });
70
+ });
71
+
72
+ rightButton.addEventListener('click', () => {
73
+ scrollContainer.scrollBy({
74
+ left: 100,
75
+ behavior: 'smooth'
76
+ });
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Updates indicator visibility based on scroll position
82
+ */
83
+ const updateIndicators = (): void => {
84
+ const { scrollLeft, scrollWidth, clientWidth } = scrollContainer as HTMLElement;
85
+
86
+ // Show left indicator only when scrolled right
87
+ leftIndicator.classList.toggle('visible', scrollLeft > 0);
88
+
89
+ // Show right indicator only when more content is available to scroll
90
+ rightIndicator.classList.toggle('visible', scrollLeft + clientWidth < scrollWidth - 1);
91
+
92
+ // Update button states if present
93
+ if (showButtons) {
94
+ const leftButton = tabs.element.querySelector(`.${tabs.getClass('tabs')}-scroll-button--left`) as HTMLButtonElement;
95
+ const rightButton = tabs.element.querySelector(`.${tabs.getClass('tabs')}-scroll-button--right`) as HTMLButtonElement;
96
+
97
+ if (leftButton) {
98
+ leftButton.disabled = scrollLeft <= 0;
99
+ }
100
+
101
+ if (rightButton) {
102
+ rightButton.disabled = scrollLeft + clientWidth >= scrollWidth - 1;
103
+ }
104
+ }
105
+ };
106
+
107
+ // Initial update
108
+ updateIndicators();
109
+
110
+ // Add scroll listener
111
+ scrollContainer.addEventListener('scroll', updateIndicators);
112
+
113
+ // Add resize observer to update on container size changes
114
+ const resizeObserver = new ResizeObserver(updateIndicators);
115
+ resizeObserver.observe(scrollContainer as Element);
116
+
117
+ // Add cleanup to component destroy method
118
+ const originalDestroy = tabs.destroy;
119
+ tabs.destroy = function() {
120
+ if (resizeObserver) {
121
+ resizeObserver.disconnect();
122
+ }
123
+
124
+ scrollContainer.removeEventListener('scroll', updateIndicators);
125
+
126
+ if (leftIndicator.parentNode) {
127
+ leftIndicator.parentNode.removeChild(leftIndicator);
128
+ }
129
+
130
+ if (rightIndicator.parentNode) {
131
+ rightIndicator.parentNode.removeChild(rightIndicator);
132
+ }
133
+
134
+ if (showButtons) {
135
+ const leftButton = tabs.element.querySelector(`.${tabs.getClass('tabs')}-scroll-button--left`);
136
+ const rightButton = tabs.element.querySelector(`.${tabs.getClass('tabs')}-scroll-button--right`);
137
+
138
+ if (leftButton && leftButton.parentNode) {
139
+ leftButton.parentNode.removeChild(leftButton);
140
+ }
141
+
142
+ if (rightButton && rightButton.parentNode) {
143
+ rightButton.parentNode.removeChild(rightButton);
144
+ }
145
+ }
146
+
147
+ originalDestroy.call(this);
148
+ };
149
+ };
@@ -0,0 +1,186 @@
1
+ // src/components/tabs/state.ts
2
+ import { TabComponent } from './types';
3
+ import { TabIndicator } from './indicator';
4
+
5
+ /**
6
+ * State manager for MD3 tab states
7
+ * Handles proper state transitions between tabs
8
+ */
9
+ export interface TabsStateManager {
10
+ /**
11
+ * Activates a tab and handles state transitions
12
+ * @param tab - The tab to activate
13
+ * @param immediate - Whether to skip animation
14
+ */
15
+ activateTab: (tab: TabComponent, immediate?: boolean) => void;
16
+
17
+ /**
18
+ * Gets the currently active tab
19
+ */
20
+ getActiveTab: () => TabComponent | null;
21
+
22
+ /**
23
+ * Cleans up event listeners
24
+ */
25
+ destroy: () => void;
26
+ }
27
+
28
+ /**
29
+ * Options for creating a tabs state manager
30
+ */
31
+ export interface TabsStateOptions {
32
+ /**
33
+ * Initial tabs to manage
34
+ */
35
+ tabs: TabComponent[];
36
+
37
+ /**
38
+ * Optional callback when active tab changes
39
+ */
40
+ onChange?: (data: { tab: TabComponent; value: string }) => void;
41
+
42
+ /**
43
+ * Optional indicator component
44
+ */
45
+ indicator?: TabIndicator;
46
+ }
47
+
48
+ /**
49
+ * Creates a state manager for MD3 tabs
50
+ * @param options - State manager options
51
+ * @returns A tabs state manager instance
52
+ */
53
+ export const createTabsState = (options: TabsStateOptions): TabsStateManager => {
54
+ const { tabs = [], onChange, indicator } = options;
55
+ let activeTab: TabComponent | null = null;
56
+
57
+ // Find initial active tab if any
58
+ const initialActiveTab = tabs.find(tab => tab.isActive());
59
+ if (initialActiveTab) {
60
+ activeTab = initialActiveTab;
61
+
62
+ // Position indicator at initial active tab
63
+ if (indicator) {
64
+ // Delay initial positioning to ensure DOM is ready
65
+ setTimeout(() => {
66
+ indicator.moveToTab(initialActiveTab, true);
67
+ }, 50);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Handles ripple effect on tab activation
73
+ * @param tab - The tab to add ripple to
74
+ */
75
+ const addRippleEffect = (tab: TabComponent): void => {
76
+ if (!tab.element) return;
77
+
78
+ const ripple = tab.element.querySelector(`.${tab.getClass('tab')}-ripple`);
79
+ if (!ripple) return;
80
+
81
+ // Create a new ripple element
82
+ const rippleElement = document.createElement('span');
83
+ rippleElement.className = 'ripple';
84
+
85
+ // Position the ripple in the center
86
+ rippleElement.style.width = '100%';
87
+ rippleElement.style.height = '100%';
88
+ rippleElement.style.left = '0';
89
+ rippleElement.style.top = '0';
90
+
91
+ // Add animation
92
+ rippleElement.style.animation = 'ripple-effect 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
93
+
94
+ // Add to DOM
95
+ ripple.appendChild(rippleElement);
96
+
97
+ // Remove after animation completes
98
+ setTimeout(() => {
99
+ rippleElement.remove();
100
+ }, 400);
101
+ };
102
+
103
+ /**
104
+ * Activates a tab with proper state transitions
105
+ */
106
+ const activateTab = (tab: TabComponent, immediate = false): void => {
107
+ if (!tab || (activeTab === tab)) return;
108
+
109
+ // First deactivate the current active tab
110
+ if (activeTab) {
111
+ activeTab.deactivate();
112
+ }
113
+
114
+ // Activate the new tab
115
+ tab.activate();
116
+ activeTab = tab;
117
+
118
+ // Move indicator to this tab
119
+ if (indicator) {
120
+ // Small delay to ensure DOM updates before indicator positioning
121
+ setTimeout(() => {
122
+ indicator.moveToTab(tab, immediate);
123
+ }, 10);
124
+ }
125
+
126
+ // Add ripple effect unless immediate mode is on
127
+ if (!immediate) {
128
+ addRippleEffect(tab);
129
+ }
130
+
131
+ // Trigger change callback
132
+ if (onChange) {
133
+ onChange({
134
+ tab,
135
+ value: tab.getValue()
136
+ });
137
+ }
138
+ };
139
+
140
+ /**
141
+ * Gets the currently active tab
142
+ */
143
+ const getActiveTab = (): TabComponent | null => {
144
+ return activeTab;
145
+ };
146
+
147
+ /**
148
+ * Cleans up resources
149
+ */
150
+ const destroy = (): void => {
151
+ // Clean up any event listeners or timers
152
+ activeTab = null;
153
+ };
154
+
155
+ return {
156
+ activateTab,
157
+ getActiveTab,
158
+ destroy
159
+ };
160
+ };
161
+
162
+ /**
163
+ * Adds animation styles for ripple effects
164
+ * This is separate from indicator animations
165
+ */
166
+ export const addTabStateStyles = (): void => {
167
+ // Only add once
168
+ if (document.getElementById('tab-state-styles')) return;
169
+
170
+ const style = document.createElement('style');
171
+ style.id = 'tab-state-styles';
172
+ style.textContent = `
173
+ @keyframes ripple-effect {
174
+ 0% {
175
+ transform: scale(0);
176
+ opacity: 0.2;
177
+ }
178
+ 100% {
179
+ transform: scale(1);
180
+ opacity: 0;
181
+ }
182
+ }
183
+ `;
184
+
185
+ document.head.appendChild(style);
186
+ };