mtrl 0.3.3 → 0.3.5
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 +1 -1
- package/src/components/segmented-button/config.ts +59 -20
- package/src/components/segmented-button/index.ts +1 -1
- package/src/components/segmented-button/segment.ts +51 -97
- package/src/components/segmented-button/segmented-button.ts +114 -2
- package/src/components/segmented-button/types.ts +52 -0
- package/src/core/compose/features/icon.ts +15 -13
- package/src/styles/components/_button.scss +6 -0
- package/src/styles/components/_chip.scss +4 -5
- package/src/styles/components/_segmented-button.scss +173 -63
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/components/segmented-button/config.ts
|
|
2
2
|
import { createComponentConfig } from '../../core/config/component-config';
|
|
3
|
-
import { SegmentedButtonConfig, SelectionMode } from './types';
|
|
3
|
+
import { SegmentedButtonConfig, SelectionMode, Density } from './types';
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_CHECKMARK_ICON = `
|
|
6
6
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -13,7 +13,8 @@ export const DEFAULT_CHECKMARK_ICON = `
|
|
|
13
13
|
*/
|
|
14
14
|
export const DEFAULT_CONFIG = {
|
|
15
15
|
mode: SelectionMode.SINGLE,
|
|
16
|
-
ripple: true
|
|
16
|
+
ripple: true,
|
|
17
|
+
density: Density.DEFAULT
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -31,20 +32,56 @@ export const createBaseConfig = (config: SegmentedButtonConfig = {}): SegmentedB
|
|
|
31
32
|
* @returns {Object} Element configuration object for withElement
|
|
32
33
|
* @internal
|
|
33
34
|
*/
|
|
34
|
-
export const getContainerConfig = (config: SegmentedButtonConfig) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
35
|
+
export const getContainerConfig = (config: SegmentedButtonConfig) => {
|
|
36
|
+
const density = config.density || Density.DEFAULT;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
tag: 'div',
|
|
40
|
+
componentName: 'segmented-button',
|
|
41
|
+
attrs: {
|
|
42
|
+
role: 'group',
|
|
43
|
+
'aria-label': 'Segmented button',
|
|
44
|
+
'data-mode': config.mode || SelectionMode.SINGLE,
|
|
45
|
+
'data-density': density
|
|
46
|
+
},
|
|
47
|
+
className: [
|
|
48
|
+
config.class,
|
|
49
|
+
config.disabled ? `${config.prefix}-segmented-button--disabled` : null,
|
|
50
|
+
density !== Density.DEFAULT ? `${config.prefix}-segmented-button--${density}` : null
|
|
51
|
+
],
|
|
52
|
+
interactive: true
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Gets density-specific sizing and spacing values
|
|
58
|
+
* @param {string} density - The density level
|
|
59
|
+
* @returns {Object} CSS variables with sizing values
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
export const getDensityStyles = (density: string): Record<string, string> => {
|
|
63
|
+
switch (density) {
|
|
64
|
+
case Density.COMPACT:
|
|
65
|
+
return {
|
|
66
|
+
'--segment-padding': '4px 8px',
|
|
67
|
+
'--segment-height': '28px',
|
|
68
|
+
'--segment-font-size': '0.8125rem'
|
|
69
|
+
};
|
|
70
|
+
case Density.COMFORTABLE:
|
|
71
|
+
return {
|
|
72
|
+
'--segment-padding': '6px 12px',
|
|
73
|
+
'--segment-height': '32px',
|
|
74
|
+
'--segment-font-size': '0.875rem'
|
|
75
|
+
};
|
|
76
|
+
case Density.DEFAULT:
|
|
77
|
+
default:
|
|
78
|
+
return {
|
|
79
|
+
'--segment-padding': '8px 16px',
|
|
80
|
+
'--segment-height': '36px',
|
|
81
|
+
'--segment-font-size': '0.875rem'
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
};
|
|
48
85
|
|
|
49
86
|
/**
|
|
50
87
|
* Generates configuration for a segment element
|
|
@@ -57,6 +94,7 @@ export const getContainerConfig = (config: SegmentedButtonConfig) => ({
|
|
|
57
94
|
export const getSegmentConfig = (segment, prefix, groupDisabled = false) => {
|
|
58
95
|
const isDisabled = groupDisabled || segment.disabled;
|
|
59
96
|
|
|
97
|
+
// We use button as our base class, but add segment-specific classes for states
|
|
60
98
|
return {
|
|
61
99
|
tag: 'button',
|
|
62
100
|
attrs: {
|
|
@@ -67,10 +105,11 @@ export const getSegmentConfig = (segment, prefix, groupDisabled = false) => {
|
|
|
67
105
|
value: segment.value
|
|
68
106
|
},
|
|
69
107
|
className: [
|
|
70
|
-
`${prefix}-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
segment
|
|
108
|
+
`${prefix}-button`, // Base button class
|
|
109
|
+
`${prefix}-segmented-button-segment`, // Specific segment class
|
|
110
|
+
segment.selected ? `${prefix}-segment--selected` : null, // Selected state
|
|
111
|
+
isDisabled ? `${prefix}-segment--disabled` : null, // Disabled state
|
|
112
|
+
segment.class // Custom class if provided
|
|
74
113
|
],
|
|
75
114
|
forwardEvents: {
|
|
76
115
|
click: (component) => !isDisabled
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// src/components/segmented-button/index.ts
|
|
2
2
|
export { default, default as createSegmentedButton } from './segmented-button';
|
|
3
|
-
export { SelectionMode } from './types';
|
|
3
|
+
export { SelectionMode, Density } from './types';
|
|
4
4
|
export type { SegmentedButtonConfig, SegmentedButtonComponent, SegmentConfig, Segment } from './types';
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// src/components/segmented-button/segment.ts
|
|
2
|
-
import
|
|
3
|
-
import { createRipple } from '../../core/build/ripple';
|
|
2
|
+
import createButton from '../button';
|
|
4
3
|
import { SegmentConfig, Segment } from './types';
|
|
5
|
-
import {
|
|
4
|
+
import { DEFAULT_CHECKMARK_ICON } from './config';
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
|
-
* Creates a segment for the segmented button
|
|
7
|
+
* Creates a segment for the segmented button using the button component
|
|
9
8
|
* @param {SegmentConfig} config - Segment configuration
|
|
10
9
|
* @param {HTMLElement} container - Container element
|
|
11
10
|
* @param {string} prefix - Component prefix
|
|
@@ -21,59 +20,36 @@ export const createSegment = (
|
|
|
21
20
|
groupDisabled = false,
|
|
22
21
|
options = { ripple: true, rippleConfig: {} }
|
|
23
22
|
): Segment => {
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
23
|
+
const isDisabled = groupDisabled || config.disabled;
|
|
24
|
+
const originalIcon = config.icon;
|
|
25
|
+
const checkmarkIcon = config.checkmarkIcon || DEFAULT_CHECKMARK_ICON;
|
|
26
|
+
|
|
27
|
+
// Create segment using button component with appropriate initial icon
|
|
28
|
+
const button = createButton({
|
|
29
|
+
text: config.text,
|
|
30
|
+
// If selected and has both icon and text, use checkmark instead of the original icon
|
|
31
|
+
icon: (config.selected && config.text && originalIcon) ? checkmarkIcon : originalIcon,
|
|
32
|
+
value: config.value || config.text || '',
|
|
33
|
+
disabled: isDisabled,
|
|
34
|
+
ripple: options.ripple,
|
|
35
|
+
rippleConfig: options.rippleConfig,
|
|
36
|
+
class: config.class,
|
|
37
|
+
// No variant - we'll style it via the segmented button styles
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Add segment-specific classes
|
|
41
|
+
button.element.classList.add(`${prefix}-segmented-button-segment`);
|
|
36
42
|
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
text: config.text,
|
|
44
|
-
container: element
|
|
45
|
-
});
|
|
43
|
+
// Set initial selected state
|
|
44
|
+
if (config.selected) {
|
|
45
|
+
button.element.classList.add(`${prefix}-segment--selected`);
|
|
46
|
+
button.element.setAttribute('aria-pressed', 'true');
|
|
47
|
+
} else {
|
|
48
|
+
button.element.setAttribute('aria-pressed', 'false');
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
if (config.icon) {
|
|
51
|
-
// Create icon element
|
|
52
|
-
iconElement = createElement({
|
|
53
|
-
tag: 'span',
|
|
54
|
-
className: `${prefix}-segmentedbutton-segment-icon`,
|
|
55
|
-
html: config.icon,
|
|
56
|
-
container: element
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// Create checkmark element (hidden initially)
|
|
60
|
-
checkmarkElement = createElement({
|
|
61
|
-
tag: 'span',
|
|
62
|
-
className: `${prefix}-segmentedbutton-segment-'checkmark'`,
|
|
63
|
-
html: 'icon',
|
|
64
|
-
container: element
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
// Hide checkmark if not selected
|
|
68
|
-
if (!config.selected) {
|
|
69
|
-
checkmarkElement.style.display = 'none';
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Hide icon if selected and we have text (icon replaced by checkmark)
|
|
73
|
-
if (config.selected && config.text) {
|
|
74
|
-
iconElement.style.display = 'none';
|
|
75
|
-
}
|
|
76
|
-
}
|
|
51
|
+
// Add to container
|
|
52
|
+
container.appendChild(button.element);
|
|
77
53
|
|
|
78
54
|
/**
|
|
79
55
|
* Updates the visual state based on selection
|
|
@@ -81,45 +57,27 @@ export const createSegment = (
|
|
|
81
57
|
* @private
|
|
82
58
|
*/
|
|
83
59
|
const updateSelectedState = (selected: boolean) => {
|
|
84
|
-
element.classList.toggle(`${prefix}-
|
|
85
|
-
element.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
|
60
|
+
button.element.classList.toggle(`${prefix}-segment--selected`, selected);
|
|
61
|
+
button.element.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
|
86
62
|
|
|
87
|
-
// Handle icon/checkmark swap if we have both text and icon
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Updates the disabled state
|
|
99
|
-
* @param {boolean} disabled - Whether the segment is disabled
|
|
100
|
-
* @private
|
|
101
|
-
*/
|
|
102
|
-
const updateDisabledState = (disabled: boolean) => {
|
|
103
|
-
const isDisabled = disabled || groupDisabled;
|
|
104
|
-
element.classList.toggle(`${prefix}-segmentedbutton-segment--disabled`, isDisabled);
|
|
105
|
-
|
|
106
|
-
if (isDisabled) {
|
|
107
|
-
element.setAttribute('disabled', 'true');
|
|
108
|
-
} else {
|
|
109
|
-
element.removeAttribute('disabled');
|
|
63
|
+
// Handle icon/checkmark swap if we have both text and original icon
|
|
64
|
+
if (config.text && originalIcon) {
|
|
65
|
+
if (selected) {
|
|
66
|
+
// When selected and has both text and icon, show checkmark
|
|
67
|
+
button.setIcon(checkmarkIcon);
|
|
68
|
+
} else {
|
|
69
|
+
// When not selected, restore original icon
|
|
70
|
+
button.setIcon(originalIcon);
|
|
71
|
+
}
|
|
110
72
|
}
|
|
111
73
|
};
|
|
112
74
|
|
|
113
|
-
// Value to use for the segment
|
|
114
|
-
const value = config.value || config.text || '';
|
|
115
|
-
|
|
116
75
|
// Initialize state
|
|
117
76
|
let isSelected = config.selected || false;
|
|
118
|
-
let isDisabled = config.disabled || false;
|
|
119
77
|
|
|
120
78
|
return {
|
|
121
|
-
element,
|
|
122
|
-
value,
|
|
79
|
+
element: button.element,
|
|
80
|
+
value: config.value || config.text || '',
|
|
123
81
|
|
|
124
82
|
isSelected() {
|
|
125
83
|
return isSelected;
|
|
@@ -131,24 +89,20 @@ export const createSegment = (
|
|
|
131
89
|
},
|
|
132
90
|
|
|
133
91
|
isDisabled() {
|
|
134
|
-
return isDisabled ||
|
|
92
|
+
return button.disabled?.isDisabled?.() || false;
|
|
135
93
|
},
|
|
136
94
|
|
|
137
95
|
setDisabled(disabled: boolean) {
|
|
138
|
-
|
|
139
|
-
|
|
96
|
+
if (disabled) {
|
|
97
|
+
button.disable();
|
|
98
|
+
} else {
|
|
99
|
+
button.enable();
|
|
100
|
+
}
|
|
140
101
|
},
|
|
141
102
|
|
|
142
103
|
destroy() {
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
ripple.unmount(element);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Remove from DOM
|
|
149
|
-
if (element.parentNode) {
|
|
150
|
-
element.parentNode.removeChild(element);
|
|
151
|
-
}
|
|
104
|
+
// Use the button's built-in destroy method for cleanup
|
|
105
|
+
button.destroy();
|
|
152
106
|
}
|
|
153
107
|
};
|
|
154
108
|
};
|
|
@@ -3,19 +3,61 @@ import { pipe } from '../../core/compose/pipe';
|
|
|
3
3
|
import { createBase, withElement } from '../../core/compose/component';
|
|
4
4
|
import { withEvents, withLifecycle } from '../../core/compose/features';
|
|
5
5
|
import { createEmitter } from '../../core/state/emitter';
|
|
6
|
-
import { SegmentedButtonConfig, SegmentedButtonComponent, SelectionMode, Segment } from './types';
|
|
7
|
-
import { createBaseConfig, getContainerConfig } from './config';
|
|
6
|
+
import { SegmentedButtonConfig, SegmentedButtonComponent, SelectionMode, Density, Segment } from './types';
|
|
7
|
+
import { createBaseConfig, getContainerConfig, getDensityStyles } from './config';
|
|
8
8
|
import { createSegment } from './segment';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Creates a new Segmented Button component
|
|
12
|
+
*
|
|
13
|
+
* The Segmented Button component provides a group of related buttons that can
|
|
14
|
+
* be used for selection and filtering. It supports single or multiple selection modes,
|
|
15
|
+
* configurable density, disabled states, and event handling.
|
|
16
|
+
*
|
|
12
17
|
* @param {SegmentedButtonConfig} config - Segmented Button configuration
|
|
13
18
|
* @returns {SegmentedButtonComponent} Segmented Button component instance
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* // Create a segmented button with three segments in single selection mode
|
|
22
|
+
* const viewToggle = createSegmentedButton({
|
|
23
|
+
* segments: [
|
|
24
|
+
* { text: 'Day', value: 'day', selected: true },
|
|
25
|
+
* { text: 'Week', value: 'week' },
|
|
26
|
+
* { text: 'Month', value: 'month' }
|
|
27
|
+
* ],
|
|
28
|
+
* mode: SelectionMode.SINGLE
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* // Listen for selection changes
|
|
32
|
+
* viewToggle.on('change', (event) => {
|
|
33
|
+
* console.log('Selected view:', event.value[0]);
|
|
34
|
+
* updateCalendarView(event.value[0]);
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* // Create a compact multi-select segmented button with icons
|
|
39
|
+
* const filterOptions = createSegmentedButton({
|
|
40
|
+
* segments: [
|
|
41
|
+
* {
|
|
42
|
+
* icon: '<svg>...</svg>',
|
|
43
|
+
* text: 'Filter 1',
|
|
44
|
+
* value: 'filter1'
|
|
45
|
+
* },
|
|
46
|
+
* {
|
|
47
|
+
* icon: '<svg>...</svg>',
|
|
48
|
+
* text: 'Filter 2',
|
|
49
|
+
* value: 'filter2'
|
|
50
|
+
* }
|
|
51
|
+
* ],
|
|
52
|
+
* mode: SelectionMode.MULTI,
|
|
53
|
+
* density: Density.COMPACT
|
|
54
|
+
* });
|
|
14
55
|
*/
|
|
15
56
|
const createSegmentedButton = (config: SegmentedButtonConfig = {}): SegmentedButtonComponent => {
|
|
16
57
|
// Process configuration
|
|
17
58
|
const baseConfig = createBaseConfig(config);
|
|
18
59
|
const mode = baseConfig.mode || SelectionMode.SINGLE;
|
|
60
|
+
const density = baseConfig.density || Density.DEFAULT;
|
|
19
61
|
const emitter = createEmitter();
|
|
20
62
|
|
|
21
63
|
try {
|
|
@@ -27,6 +69,12 @@ const createSegmentedButton = (config: SegmentedButtonConfig = {}): SegmentedBut
|
|
|
27
69
|
withLifecycle()
|
|
28
70
|
)(baseConfig);
|
|
29
71
|
|
|
72
|
+
// Apply density styles
|
|
73
|
+
const densityStyles = getDensityStyles(density as string);
|
|
74
|
+
Object.entries(densityStyles).forEach(([prop, value]) => {
|
|
75
|
+
component.element.style.setProperty(prop, value);
|
|
76
|
+
});
|
|
77
|
+
|
|
30
78
|
// Create segments
|
|
31
79
|
const segments: Segment[] = [];
|
|
32
80
|
if (baseConfig.segments && baseConfig.segments.length) {
|
|
@@ -125,6 +173,34 @@ const createSegmentedButton = (config: SegmentedButtonConfig = {}): SegmentedBut
|
|
|
125
173
|
*/
|
|
126
174
|
const findSegmentByValue = (value: string) => segments.find(segment => segment.value === value);
|
|
127
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Updates the density of the segmented button
|
|
178
|
+
* @param {string} newDensity - New density value
|
|
179
|
+
* @private
|
|
180
|
+
*/
|
|
181
|
+
const updateDensity = (newDensity: string) => {
|
|
182
|
+
// Remove existing density classes
|
|
183
|
+
[Density.DEFAULT, Density.COMFORTABLE, Density.COMPACT].forEach(d => {
|
|
184
|
+
if (d !== Density.DEFAULT) {
|
|
185
|
+
component.element.classList.remove(`${baseConfig.prefix}-segmented-button--${d}`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Add new density class if not default
|
|
190
|
+
if (newDensity !== Density.DEFAULT) {
|
|
191
|
+
component.element.classList.add(`${baseConfig.prefix}-segmented-button--${newDensity}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Update data attribute
|
|
195
|
+
component.element.setAttribute('data-density', newDensity);
|
|
196
|
+
|
|
197
|
+
// Apply density styles
|
|
198
|
+
const densityStyles = getDensityStyles(newDensity);
|
|
199
|
+
Object.entries(densityStyles).forEach(([prop, value]) => {
|
|
200
|
+
component.element.style.setProperty(prop, value);
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
128
204
|
// Create the component API
|
|
129
205
|
const segmentedButton: SegmentedButtonComponent = {
|
|
130
206
|
element: component.element,
|
|
@@ -205,15 +281,51 @@ const createSegmentedButton = (config: SegmentedButtonConfig = {}): SegmentedBut
|
|
|
205
281
|
enable() {
|
|
206
282
|
// Enable the entire component
|
|
207
283
|
component.element.classList.remove(`${baseConfig.prefix}-segmented-button--disabled`);
|
|
284
|
+
// Enable all segments (unless individually disabled)
|
|
285
|
+
segments.forEach(segment => {
|
|
286
|
+
// Only enable if it wasn't individually disabled
|
|
287
|
+
if (!baseConfig.segments?.find(s => s.value === segment.value)?.disabled) {
|
|
288
|
+
segment.setDisabled(false);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
208
291
|
return this;
|
|
209
292
|
},
|
|
210
293
|
|
|
211
294
|
disable() {
|
|
212
295
|
// Disable the entire component
|
|
213
296
|
component.element.classList.add(`${baseConfig.prefix}-segmented-button--disabled`);
|
|
297
|
+
// Disable all segments
|
|
298
|
+
segments.forEach(segment => {
|
|
299
|
+
segment.setDisabled(true);
|
|
300
|
+
});
|
|
214
301
|
return this;
|
|
215
302
|
},
|
|
216
303
|
|
|
304
|
+
enableSegment(value) {
|
|
305
|
+
const segment = findSegmentByValue(value);
|
|
306
|
+
if (segment) {
|
|
307
|
+
segment.setDisabled(false);
|
|
308
|
+
}
|
|
309
|
+
return this;
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
disableSegment(value) {
|
|
313
|
+
const segment = findSegmentByValue(value);
|
|
314
|
+
if (segment) {
|
|
315
|
+
segment.setDisabled(true);
|
|
316
|
+
}
|
|
317
|
+
return this;
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
setDensity(newDensity) {
|
|
321
|
+
updateDensity(newDensity);
|
|
322
|
+
return this;
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
getDensity() {
|
|
326
|
+
return component.element.getAttribute('data-density') || Density.DEFAULT;
|
|
327
|
+
},
|
|
328
|
+
|
|
217
329
|
on(event, handler) {
|
|
218
330
|
emitter.on(event, handler);
|
|
219
331
|
return this;
|
|
@@ -11,6 +11,20 @@ export enum SelectionMode {
|
|
|
11
11
|
MULTI = 'multi'
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Density options for segmented button
|
|
16
|
+
* Controls the overall sizing and spacing of the component.
|
|
17
|
+
* @category Components
|
|
18
|
+
*/
|
|
19
|
+
export enum Density {
|
|
20
|
+
/** Default size with standard spacing */
|
|
21
|
+
DEFAULT = 'default',
|
|
22
|
+
/** Reduced size and spacing, more compact */
|
|
23
|
+
COMFORTABLE = 'comfortable',
|
|
24
|
+
/** Minimal size and spacing, most compact */
|
|
25
|
+
COMPACT = 'compact'
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
/**
|
|
15
29
|
* Event types for segmented button
|
|
16
30
|
*/
|
|
@@ -77,6 +91,11 @@ export interface SegmentConfig {
|
|
|
77
91
|
* Additional CSS class names for this segment
|
|
78
92
|
*/
|
|
79
93
|
class?: string;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Custom icon to display when segment is selected (replaces default checkmark)
|
|
97
|
+
*/
|
|
98
|
+
checkmarkIcon?: string;
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
/**
|
|
@@ -122,6 +141,12 @@ export interface SegmentedButtonConfig {
|
|
|
122
141
|
* @default true
|
|
123
142
|
*/
|
|
124
143
|
ripple?: boolean;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Density setting that controls the component's size and spacing
|
|
147
|
+
* @default Density.DEFAULT
|
|
148
|
+
*/
|
|
149
|
+
density?: Density | string;
|
|
125
150
|
|
|
126
151
|
/**
|
|
127
152
|
* Ripple effect configuration
|
|
@@ -230,6 +255,33 @@ export interface SegmentedButtonComponent {
|
|
|
230
255
|
* @returns The SegmentedButtonComponent for chaining
|
|
231
256
|
*/
|
|
232
257
|
disable: () => SegmentedButtonComponent;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Enables a specific segment by its value
|
|
261
|
+
* @param value - The value of the segment to enable
|
|
262
|
+
* @returns The SegmentedButtonComponent for chaining
|
|
263
|
+
*/
|
|
264
|
+
enableSegment: (value: string) => SegmentedButtonComponent;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Disables a specific segment by its value
|
|
268
|
+
* @param value - The value of the segment to disable
|
|
269
|
+
* @returns The SegmentedButtonComponent for chaining
|
|
270
|
+
*/
|
|
271
|
+
disableSegment: (value: string) => SegmentedButtonComponent;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Sets the density of the segmented button
|
|
275
|
+
* @param density - The density level to set
|
|
276
|
+
* @returns The SegmentedButtonComponent for chaining
|
|
277
|
+
*/
|
|
278
|
+
setDensity: (density: Density | string) => SegmentedButtonComponent;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Gets the current density setting
|
|
282
|
+
* @returns The current density
|
|
283
|
+
*/
|
|
284
|
+
getDensity: () => string;
|
|
233
285
|
|
|
234
286
|
/**
|
|
235
287
|
* Adds an event listener to the segmented button
|
|
@@ -41,29 +41,31 @@ const updateCircularStyle = (component: ElementComponent, config: IconConfig): v
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Adds icon management to a component
|
|
44
|
-
*
|
|
45
44
|
* @param config - Configuration object containing icon information
|
|
46
45
|
* @returns Function that enhances a component with icon capabilities
|
|
47
46
|
*/
|
|
48
47
|
export const withIcon = <T extends IconConfig>(config: T) =>
|
|
49
48
|
<C extends ElementComponent>(component: C): C & IconComponent => {
|
|
49
|
+
// Create the icon with configuration settings
|
|
50
50
|
const icon = createIcon(component.element, {
|
|
51
51
|
prefix: config.prefix,
|
|
52
52
|
type: config.componentName || 'component',
|
|
53
53
|
position: config.iconPosition,
|
|
54
54
|
iconSize: config.iconSize
|
|
55
55
|
});
|
|
56
|
-
|
|
57
|
-
if
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
|
|
57
|
+
// Set icon if provided in config
|
|
58
|
+
config.icon && icon.setIcon(config.icon);
|
|
59
|
+
|
|
60
|
+
// Apply button-specific styling if the component is a button
|
|
61
61
|
if (component.componentName === 'button') {
|
|
62
|
-
|
|
62
|
+
if (!config.text) {
|
|
63
|
+
updateCircularStyle(component, config);
|
|
64
|
+
} else if (config.icon && config.text) {
|
|
65
|
+
component.element.classList.add(`${component.getClass('button')}--icon`);
|
|
66
|
+
}
|
|
63
67
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
};
|
|
69
|
-
};
|
|
68
|
+
|
|
69
|
+
// Return enhanced component with icon capabilities
|
|
70
|
+
return Object.assign(component, { icon });
|
|
71
|
+
};
|
|
@@ -124,7 +124,7 @@ $container: '#{base.$prefix}-chips';
|
|
|
124
124
|
background-color: t.color('on-surface');
|
|
125
125
|
opacity: 0.08;
|
|
126
126
|
pointer-events: none;
|
|
127
|
-
border-radius:
|
|
127
|
+
border-radius: v.chip('border-radius');
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
&:active::after {
|
|
@@ -253,7 +253,6 @@ $container: '#{base.$prefix}-chips';
|
|
|
253
253
|
|
|
254
254
|
// Filter chip
|
|
255
255
|
&--filter {
|
|
256
|
-
|
|
257
256
|
color: t.color('on-surface');
|
|
258
257
|
position: relative;
|
|
259
258
|
border: 1px solid t.alpha('outline', v.chip('outlined-border-opacity'));
|
|
@@ -266,7 +265,7 @@ $container: '#{base.$prefix}-chips';
|
|
|
266
265
|
background-color: t.color('on-surface');
|
|
267
266
|
opacity: 0.08;
|
|
268
267
|
pointer-events: none;
|
|
269
|
-
border-radius:
|
|
268
|
+
border-radius: calc(v.chip('border-radius') - 1px);
|
|
270
269
|
}
|
|
271
270
|
|
|
272
271
|
&.#{$component}--selected {
|
|
@@ -309,7 +308,7 @@ $container: '#{base.$prefix}-chips';
|
|
|
309
308
|
background-color: t.color('on-secondary-container');
|
|
310
309
|
opacity: 0.08;
|
|
311
310
|
pointer-events: none;
|
|
312
|
-
border-radius:
|
|
311
|
+
border-radius: calc(v.chip('border-radius') - 1px);
|
|
313
312
|
}
|
|
314
313
|
}
|
|
315
314
|
}
|
|
@@ -330,7 +329,7 @@ $container: '#{base.$prefix}-chips';
|
|
|
330
329
|
background-color: t.color('on-surface');
|
|
331
330
|
opacity: 0.08;
|
|
332
331
|
pointer-events: none;
|
|
333
|
-
border-radius:
|
|
332
|
+
border-radius: v.chip('border-radius');
|
|
334
333
|
}
|
|
335
334
|
|
|
336
335
|
&.#{$component}--selected {
|
|
@@ -13,49 +13,103 @@ $component: '#{base.$prefix}-segmented-button';
|
|
|
13
13
|
display: inline-flex;
|
|
14
14
|
align-items: center;
|
|
15
15
|
justify-content: center;
|
|
16
|
-
|
|
17
|
-
border-radius: v.shape('full');
|
|
16
|
+
border-radius: 20px; // Half of height per MD3 specs
|
|
18
17
|
border: 1px solid t.color('outline');
|
|
19
18
|
background-color: transparent;
|
|
20
19
|
overflow: hidden;
|
|
21
20
|
|
|
22
|
-
//
|
|
21
|
+
// Density variables with defaults (Material Design 3 standard density)
|
|
22
|
+
--segment-height: 40px;
|
|
23
|
+
--segment-padding-x: 24px;
|
|
24
|
+
--segment-padding-icon-only: 12px;
|
|
25
|
+
--segment-padding-icon-text-left: 16px;
|
|
26
|
+
--segment-padding-icon-text-right: 24px;
|
|
27
|
+
--segment-icon-size: 18px;
|
|
28
|
+
--segment-text-size: 0.875rem;
|
|
29
|
+
--segment-border-radius: 20px;
|
|
30
|
+
|
|
31
|
+
// Set height from the CSS variable
|
|
32
|
+
height: var(--segment-height);
|
|
33
|
+
// Adjust border radius based on height
|
|
34
|
+
border-radius: calc(var(--segment-height) / 2);
|
|
35
|
+
|
|
36
|
+
// Comfortable density (medium)
|
|
37
|
+
&--comfortable {
|
|
38
|
+
--segment-height: 36px;
|
|
39
|
+
--segment-padding-x: 20px;
|
|
40
|
+
--segment-padding-icon-only: 10px;
|
|
41
|
+
--segment-padding-icon-text-left: 14px;
|
|
42
|
+
--segment-padding-icon-text-right: 20px;
|
|
43
|
+
--segment-icon-size: 16px;
|
|
44
|
+
--segment-text-size: 0.8125rem;
|
|
45
|
+
--segment-border-radius: 18px;
|
|
46
|
+
|
|
47
|
+
border-radius: var(--segment-border-radius);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Compact density (small)
|
|
51
|
+
&--compact {
|
|
52
|
+
--segment-height: 32px;
|
|
53
|
+
--segment-padding-x: 16px;
|
|
54
|
+
--segment-padding-icon-only: 8px;
|
|
55
|
+
--segment-padding-icon-text-left: 12px;
|
|
56
|
+
--segment-padding-icon-text-right: 16px;
|
|
57
|
+
--segment-icon-size: 16px;
|
|
58
|
+
--segment-text-size: 0.75rem;
|
|
59
|
+
--segment-border-radius: 16px;
|
|
60
|
+
|
|
61
|
+
border-radius: var(--segment-border-radius);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Disabled state for whole component
|
|
23
65
|
&--disabled {
|
|
24
66
|
opacity: 0.38;
|
|
25
67
|
pointer-events: none;
|
|
26
68
|
}
|
|
27
69
|
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
align-items: center;
|
|
34
|
-
justify-content: center;
|
|
35
|
-
height: 100%;
|
|
36
|
-
min-width: 48px;
|
|
37
|
-
padding: 0 12px;
|
|
38
|
-
border: none;
|
|
70
|
+
// Style for button elements used as segments
|
|
71
|
+
.#{base.$prefix}-button {
|
|
72
|
+
// Reset button styles that we don't want
|
|
73
|
+
margin: 0;
|
|
74
|
+
box-shadow: none;
|
|
39
75
|
background-color: transparent;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
76
|
+
border: none;
|
|
77
|
+
position: relative; // For pseudo-elements
|
|
78
|
+
border-radius: 0; // Reset any border-radius
|
|
79
|
+
min-width: 48px;
|
|
80
|
+
height: 100%;
|
|
81
|
+
color: t.color('on-surface'); // Original color
|
|
82
|
+
|
|
83
|
+
// Use CSS variables for dynamic sizing based on density
|
|
84
|
+
padding: 0 var(--segment-padding-x);
|
|
43
85
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
86
|
+
// Icon-only segments have equal padding all around
|
|
87
|
+
&.#{base.$prefix}-button--circular {
|
|
88
|
+
padding: 0 var(--segment-padding-icon-only);
|
|
47
89
|
}
|
|
48
90
|
|
|
49
|
-
//
|
|
50
|
-
|
|
91
|
+
// Segments with both icon and text
|
|
92
|
+
&:has(.#{base.$prefix}-button-icon + .#{base.$prefix}-button-text) {
|
|
93
|
+
padding: 0 var(--segment-padding-icon-text-right) 0 var(--segment-padding-icon-text-left);
|
|
94
|
+
}
|
|
51
95
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
96
|
+
// Only add border radius to first and last segments
|
|
97
|
+
&:first-child {
|
|
98
|
+
border-top-left-radius: var(--segment-border-radius);
|
|
99
|
+
border-bottom-left-radius: var(--segment-border-radius);
|
|
100
|
+
}
|
|
57
101
|
|
|
58
|
-
|
|
102
|
+
&:last-child {
|
|
103
|
+
border-top-right-radius: var(--segment-border-radius);
|
|
104
|
+
border-bottom-right-radius: var(--segment-border-radius);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Hover state - keeping original color
|
|
108
|
+
&:hover:not([disabled]) {
|
|
109
|
+
background-color: t.alpha('on-surface', 0.08);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Focus state
|
|
59
113
|
&:focus {
|
|
60
114
|
outline: none;
|
|
61
115
|
}
|
|
@@ -65,53 +119,109 @@ $component: '#{base.$prefix}-segmented-button';
|
|
|
65
119
|
outline-offset: -2px;
|
|
66
120
|
}
|
|
67
121
|
|
|
68
|
-
|
|
69
|
-
|
|
122
|
+
// Replace border with pseudo-elements for better control
|
|
123
|
+
// Each segment has its own right border (except last)
|
|
124
|
+
&:not(:last-child)::after {
|
|
125
|
+
content: '';
|
|
126
|
+
position: absolute;
|
|
127
|
+
top: 0;
|
|
128
|
+
right: 0;
|
|
129
|
+
height: 100%;
|
|
130
|
+
width: 1px;
|
|
131
|
+
background-color: t.color('outline');
|
|
132
|
+
pointer-events: none;
|
|
70
133
|
}
|
|
71
134
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
135
|
+
// Disabled state handling
|
|
136
|
+
&[disabled] {
|
|
137
|
+
opacity: 0.38;
|
|
138
|
+
|
|
139
|
+
// When a disabled button has a right border, make it lower opacity
|
|
140
|
+
&::after {
|
|
141
|
+
background-color: t.alpha('outline', 0.38);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// When a disabled button is before a non-disabled button, let the non-disabled handle the border
|
|
145
|
+
+ .#{base.$prefix}-button:not([disabled])::before {
|
|
146
|
+
content: '';
|
|
147
|
+
position: absolute;
|
|
148
|
+
top: 0;
|
|
149
|
+
left: 0;
|
|
150
|
+
height: 100%;
|
|
151
|
+
width: 1px;
|
|
152
|
+
background-color: t.color('outline');
|
|
153
|
+
pointer-events: none;
|
|
154
|
+
}
|
|
76
155
|
|
|
77
|
-
|
|
78
|
-
|
|
156
|
+
// When a disabled button is after a non-disabled button, make the non-disabled button's border low opacity
|
|
157
|
+
&:not(:first-child) {
|
|
158
|
+
&::before {
|
|
159
|
+
content: '';
|
|
160
|
+
position: absolute;
|
|
161
|
+
top: 0;
|
|
162
|
+
left: 0;
|
|
163
|
+
height: 100%;
|
|
164
|
+
width: 1px;
|
|
165
|
+
background-color: t.alpha('outline', 0.38);
|
|
166
|
+
pointer-events: none;
|
|
167
|
+
}
|
|
79
168
|
}
|
|
80
169
|
}
|
|
81
170
|
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
171
|
+
// Ensure all button styles are reset appropriately
|
|
172
|
+
&.#{base.$prefix}-button--filled,
|
|
173
|
+
&.#{base.$prefix}-button--outlined,
|
|
174
|
+
&.#{base.$prefix}-button--tonal,
|
|
175
|
+
&.#{base.$prefix}-button--elevated,
|
|
176
|
+
&.#{base.$prefix}-button--text {
|
|
177
|
+
background-color: transparent;
|
|
178
|
+
box-shadow: none;
|
|
179
|
+
color: t.color('on-surface');
|
|
86
180
|
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Selected state
|
|
184
|
+
.#{base.$prefix}-segment--selected {
|
|
185
|
+
background-color: t.color('secondary-container');
|
|
186
|
+
color: t.color('on-secondary-container');
|
|
87
187
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// For when both icon and text are present
|
|
91
|
-
margin: 0 auto;
|
|
188
|
+
&:hover:not([disabled]) {
|
|
189
|
+
background-color: t.alpha('secondary-container', 0.8);
|
|
92
190
|
}
|
|
93
191
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
svg {
|
|
103
|
-
width: 18px;
|
|
104
|
-
height: 18px;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
+ .#{$component}-segment-text {
|
|
108
|
-
margin-left: 8px;
|
|
109
|
-
}
|
|
192
|
+
// Ensure color change even with different button variants
|
|
193
|
+
&.#{base.$prefix}-button--filled,
|
|
194
|
+
&.#{base.$prefix}-button--outlined,
|
|
195
|
+
&.#{base.$prefix}-button--tonal,
|
|
196
|
+
&.#{base.$prefix}-button--elevated,
|
|
197
|
+
&.#{base.$prefix}-button--text {
|
|
198
|
+
background-color: t.color('secondary-container');
|
|
199
|
+
color: t.color('on-secondary-container');
|
|
110
200
|
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Ensure proper spacing in button contents
|
|
204
|
+
.#{base.$prefix}-button-text {
|
|
205
|
+
margin: 0;
|
|
206
|
+
white-space: nowrap;
|
|
207
|
+
@include m.typography('label-large');
|
|
208
|
+
// Apply density-specific font sizing
|
|
209
|
+
font-size: var(--segment-text-size);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.#{base.$prefix}-button-icon + .#{base.$prefix}-button-text {
|
|
213
|
+
margin-left: 8px; // MD3 spec for space between icon and text
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Icon sizing per MD3
|
|
217
|
+
.#{base.$prefix}-button-icon {
|
|
218
|
+
display: flex;
|
|
219
|
+
align-items: center;
|
|
220
|
+
justify-content: center;
|
|
111
221
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
222
|
+
svg {
|
|
223
|
+
width: var(--segment-icon-size);
|
|
224
|
+
height: var(--segment-icon-size);
|
|
115
225
|
}
|
|
116
226
|
}
|
|
117
227
|
}
|