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.
- package/package.json +6 -3
- package/src/components/badge/_styles.scss +9 -9
- package/src/components/button/_styles.scss +0 -56
- package/src/components/button/button.ts +0 -2
- package/src/components/button/constants.ts +0 -6
- package/src/components/button/index.ts +2 -2
- package/src/components/button/types.ts +1 -7
- package/src/components/card/_styles.scss +67 -25
- package/src/components/card/api.ts +54 -3
- package/src/components/card/card.ts +33 -2
- package/src/components/card/config.ts +143 -21
- package/src/components/card/constants.ts +20 -19
- package/src/components/card/content.ts +299 -2
- package/src/components/card/features.ts +155 -4
- package/src/components/card/index.ts +31 -9
- package/src/components/card/types.ts +138 -15
- package/src/components/chip/chip.ts +1 -9
- package/src/components/chip/constants.ts +0 -10
- package/src/components/chip/index.ts +1 -1
- package/src/components/chip/types.ts +1 -4
- package/src/components/progress/_styles.scss +0 -65
- package/src/components/progress/config.ts +1 -2
- package/src/components/progress/constants.ts +0 -14
- package/src/components/progress/index.ts +1 -1
- package/src/components/progress/progress.ts +1 -4
- package/src/components/progress/types.ts +1 -4
- package/src/components/radios/_styles.scss +0 -45
- package/src/components/radios/api.ts +85 -60
- package/src/components/radios/config.ts +1 -2
- package/src/components/radios/constants.ts +0 -9
- package/src/components/radios/index.ts +1 -1
- package/src/components/radios/radio.ts +34 -11
- package/src/components/radios/radios.ts +2 -1
- package/src/components/radios/types.ts +1 -7
- package/src/components/slider/_styles.scss +193 -281
- package/src/components/slider/accessibility.md +59 -0
- package/src/components/slider/api.ts +36 -101
- package/src/components/slider/config.ts +29 -78
- package/src/components/slider/constants.ts +12 -8
- package/src/components/slider/features/appearance.ts +1 -47
- package/src/components/slider/features/disabled.ts +41 -16
- package/src/components/slider/features/interactions.ts +166 -26
- package/src/components/slider/features/keyboard.ts +125 -6
- package/src/components/slider/features/structure.ts +182 -195
- package/src/components/slider/features/ui.ts +234 -303
- package/src/components/slider/index.ts +11 -1
- package/src/components/slider/slider.ts +1 -1
- package/src/components/slider/types.ts +10 -25
- package/src/components/tabs/_styles.scss +285 -155
- package/src/components/tabs/api.ts +178 -400
- package/src/components/tabs/config.ts +46 -52
- package/src/components/tabs/constants.ts +85 -8
- package/src/components/tabs/features.ts +401 -0
- package/src/components/tabs/index.ts +60 -3
- package/src/components/tabs/indicator.ts +225 -0
- package/src/components/tabs/responsive.ts +144 -0
- package/src/components/tabs/scroll-indicators.ts +149 -0
- package/src/components/tabs/state.ts +186 -0
- package/src/components/tabs/tab-api.ts +258 -0
- package/src/components/tabs/tab.ts +255 -0
- package/src/components/tabs/tabs.ts +50 -31
- package/src/components/tabs/types.ts +324 -128
- package/src/components/tabs/utils.ts +107 -0
- package/src/components/textfield/_styles.scss +0 -98
- package/src/components/textfield/config.ts +2 -3
- package/src/components/textfield/constants.ts +0 -14
- package/src/components/textfield/index.ts +2 -2
- package/src/components/textfield/textfield.ts +0 -2
- package/src/components/textfield/types.ts +1 -4
- package/src/core/compose/component.ts +1 -1
- package/src/core/compose/features/badge.ts +79 -0
- package/src/core/compose/features/index.ts +3 -1
- package/src/styles/abstract/_theme.scss +106 -2
- package/src/components/card/actions.ts +0 -48
- package/src/components/card/header.ts +0 -88
- 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
|
+
};
|