mtrl 0.3.2 → 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/CONTRIBUTING.md +56 -17
- package/package.json +1 -1
- package/src/components/navigation/nav-item.ts +13 -2
- 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/core/dom/create.ts +57 -51
- 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/src/styles/themes/_autumn.scss +3 -0
package/CONTRIBUTING.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# Contributing to mtrl
|
|
2
2
|
|
|
3
|
-
Thank you for your interest in contributing to mtrl! This document provides guidelines and instructions for contributing to this lightweight,
|
|
3
|
+
Thank you for your interest in contributing to mtrl! This document provides guidelines and instructions for contributing to this lightweight, TypeScript-focused UI component library.
|
|
4
4
|
|
|
5
5
|
## Why Contribute?
|
|
6
6
|
|
|
7
7
|
mtrl aims to be a modern, flexible UI component library with:
|
|
8
8
|
|
|
9
9
|
- Zero dependencies (except Bun for development)
|
|
10
|
-
-
|
|
10
|
+
- TypeScript-first codebase
|
|
11
11
|
- Lightweight, tree-shakable components
|
|
12
12
|
- Simple and extensible API
|
|
13
13
|
- Excellent documentation
|
|
14
14
|
|
|
15
|
-
By contributing to mtrl, you'll help create a lean alternative to heavier frameworks while gaining experience with modern
|
|
15
|
+
By contributing to mtrl, you'll help create a lean alternative to heavier frameworks while gaining experience with modern TypeScript patterns and component design.
|
|
16
16
|
|
|
17
17
|
## Getting Started
|
|
18
18
|
|
|
@@ -76,16 +76,29 @@ mtrl uses a separate repository called mtrl.app (https://mtrl.app) for showcasin
|
|
|
76
76
|
|
|
77
77
|
mtrl components follow a consistent pattern:
|
|
78
78
|
|
|
79
|
-
```
|
|
80
|
-
// src/components/mycomponent/index.
|
|
79
|
+
```typescript
|
|
80
|
+
// src/components/mycomponent/index.ts
|
|
81
81
|
export { createMyComponent } from './mycomponent';
|
|
82
|
+
export type { MyComponentOptions } from './types';
|
|
82
83
|
|
|
83
|
-
// src/components/mycomponent/
|
|
84
|
+
// src/components/mycomponent/types.ts
|
|
85
|
+
export interface MyComponentOptions {
|
|
86
|
+
text?: string;
|
|
87
|
+
onClick?: (event: MouseEvent) => void;
|
|
88
|
+
// other options...
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/components/mycomponent/mycomponent.ts
|
|
84
92
|
import { createElement } from '../../core/dom/create';
|
|
85
93
|
import { createLifecycle } from '../../core/state/lifecycle';
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
import type { MyComponentOptions } from './types';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Creates a new MyComponent instance
|
|
98
|
+
* @param options - Configuration options for MyComponent
|
|
99
|
+
* @returns The MyComponent instance
|
|
100
|
+
*/
|
|
101
|
+
export const createMyComponent = (options: MyComponentOptions = {}) => {
|
|
89
102
|
// Create DOM elements
|
|
90
103
|
const element = createElement({...});
|
|
91
104
|
|
|
@@ -108,21 +121,30 @@ export const createMyComponent = (options = {}) => {
|
|
|
108
121
|
The mtrl.app showcase application is the best way to develop and test your components:
|
|
109
122
|
|
|
110
123
|
1. Clone the mtrl.app repository alongside your mtrl clone.
|
|
111
|
-
2. Create a new view file in `src/client/
|
|
112
|
-
3. Add the route in `src/client/core/navigation.
|
|
124
|
+
2. Create a new view file in `src/client/content/components/` for your component.
|
|
125
|
+
3. Add the route in `src/client/core/navigation.ts` of the mtrl.app repository.
|
|
113
126
|
4. Implement different variants and states for testing.
|
|
114
127
|
5. Run the showcase server with `bun run dev` in the mtrl.app directory.
|
|
115
128
|
|
|
116
129
|
This separation of the library code (mtrl) and the showcase app (mtrl.app) keeps the core library clean while providing a rich development environment.
|
|
117
130
|
|
|
131
|
+
### TypeScript Standards
|
|
132
|
+
|
|
133
|
+
- Use TypeScript's type system to create clear interfaces and types
|
|
134
|
+
- Export types and interfaces separately from implementations
|
|
135
|
+
- Use strict typing and avoid `any` when possible
|
|
136
|
+
- Prefer interfaces for public APIs and type aliases for complex types
|
|
137
|
+
- Add proper return types to all functions
|
|
138
|
+
|
|
118
139
|
### Coding Standards
|
|
119
140
|
|
|
120
|
-
-
|
|
121
|
-
-
|
|
141
|
+
- Add file path as a comment on the first line of each file
|
|
142
|
+
- Use functional programming principles when possible
|
|
122
143
|
- Use consistent naming conventions:
|
|
123
144
|
- Factory functions should be named `createXyz`
|
|
124
145
|
- Utilities should use clear, descriptive names
|
|
125
|
-
-
|
|
146
|
+
- Interfaces should be named in PascalCase (e.g., `ButtonOptions`)
|
|
147
|
+
- Write TypeDoc comments for all public functions and types
|
|
126
148
|
|
|
127
149
|
### CSS/SCSS Guidelines
|
|
128
150
|
|
|
@@ -143,7 +165,7 @@ This separation of the library code (mtrl) and the showcase app (mtrl.app) keeps
|
|
|
143
165
|
|
|
144
166
|
Please add appropriate tests for your changes:
|
|
145
167
|
|
|
146
|
-
```
|
|
168
|
+
```typescript
|
|
147
169
|
// Example test structure
|
|
148
170
|
describe('myComponent', () => {
|
|
149
171
|
it('should render correctly', () => {
|
|
@@ -158,12 +180,29 @@ describe('myComponent', () => {
|
|
|
158
180
|
|
|
159
181
|
## Documentation
|
|
160
182
|
|
|
161
|
-
|
|
183
|
+
Documentation is crucial for this project:
|
|
162
184
|
|
|
163
|
-
- Add
|
|
185
|
+
- Add TypeDoc comments for all public API methods and types
|
|
186
|
+
- Comment the file path at the top of each file
|
|
164
187
|
- Update the component's README.md (if applicable)
|
|
165
188
|
- Consider adding example code in the playground
|
|
166
189
|
|
|
190
|
+
Example of proper TypeDoc:
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
/**
|
|
194
|
+
* Creates a button element with specified options
|
|
195
|
+
*
|
|
196
|
+
* @param options - The button configuration options
|
|
197
|
+
* @returns A button component instance
|
|
198
|
+
* @example
|
|
199
|
+
* ```ts
|
|
200
|
+
* const button = createButton({ text: 'Click me', variant: 'primary' });
|
|
201
|
+
* document.body.appendChild(button.element);
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
```
|
|
205
|
+
|
|
167
206
|
## Community and Communication
|
|
168
207
|
|
|
169
208
|
- Submit issues for bugs or feature requests
|
package/package.json
CHANGED
|
@@ -107,11 +107,22 @@ export const createNavItem = (
|
|
|
107
107
|
itemElement.appendChild(icon);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
// Add label if provided
|
|
110
|
+
// Add label if provided - CONVERTED TO ANCHOR FOR SEO
|
|
111
111
|
if (config.label) {
|
|
112
|
-
|
|
112
|
+
// Create anchor instead of span for the label
|
|
113
|
+
const label = document.createElement('a');
|
|
113
114
|
label.className = `${prefix}-${NavClass.LABEL}`;
|
|
114
115
|
label.textContent = config.label;
|
|
116
|
+
label.href = `/${config.id}`; // Create path based on ID
|
|
117
|
+
|
|
118
|
+
// Ensure the anchor looks the same as a span would
|
|
119
|
+
label.style.textDecoration = 'none';
|
|
120
|
+
label.style.color = 'inherit';
|
|
121
|
+
label.style.pointerEvents = 'none'; // Let the button handle click events
|
|
122
|
+
|
|
123
|
+
// Add SEO attributes
|
|
124
|
+
label.setAttribute('title', config.label);
|
|
125
|
+
|
|
115
126
|
itemElement.appendChild(label);
|
|
116
127
|
itemElement.setAttribute('aria-label', config.label);
|
|
117
128
|
}
|
|
@@ -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
|
+
};
|
package/src/core/dom/create.ts
CHANGED
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
import { setAttributes } from './attributes';
|
|
8
8
|
import { normalizeClasses } from '../utils';
|
|
9
|
+
import { PREFIX } from '../config';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Event handler function type
|
|
13
|
+
*/
|
|
14
|
+
export type EventHandler = (event: Event) => void;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Event condition type - either a boolean or a function that returns a boolean
|
|
18
|
+
*/
|
|
19
|
+
export type EventCondition = boolean | ((context: any, event: Event) => boolean);
|
|
9
20
|
|
|
10
21
|
/**
|
|
11
22
|
* Options for element creation
|
|
@@ -59,7 +70,7 @@ export interface CreateElementOptions {
|
|
|
59
70
|
/**
|
|
60
71
|
* Events to forward when component has emit method
|
|
61
72
|
*/
|
|
62
|
-
forwardEvents?: Record<string,
|
|
73
|
+
forwardEvents?: Record<string, EventCondition>;
|
|
63
74
|
|
|
64
75
|
/**
|
|
65
76
|
* Callback after element creation
|
|
@@ -81,9 +92,12 @@ export interface CreateElementOptions {
|
|
|
81
92
|
* Event handler storage to facilitate cleanup
|
|
82
93
|
*/
|
|
83
94
|
export interface EventHandlerStorage {
|
|
84
|
-
[eventName: string]:
|
|
95
|
+
[eventName: string]: EventHandler;
|
|
85
96
|
}
|
|
86
97
|
|
|
98
|
+
// Constant for prefix with dash
|
|
99
|
+
const PREFIX_WITH_DASH = `${PREFIX}-`;
|
|
100
|
+
|
|
87
101
|
/**
|
|
88
102
|
* Creates a DOM element with the specified options
|
|
89
103
|
*
|
|
@@ -108,89 +122,78 @@ export const createElement = (options: CreateElementOptions = {}): HTMLElement =
|
|
|
108
122
|
|
|
109
123
|
const element = document.createElement(tag);
|
|
110
124
|
|
|
111
|
-
//
|
|
125
|
+
// Apply basic properties
|
|
112
126
|
if (html) element.innerHTML = html;
|
|
113
127
|
if (text) element.textContent = text;
|
|
114
128
|
if (id) element.id = id;
|
|
115
129
|
|
|
116
130
|
// Handle classes
|
|
117
131
|
if (className) {
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
120
|
-
|
|
132
|
+
const classes = normalizeClasses(className);
|
|
133
|
+
if (classes.length) {
|
|
134
|
+
// Apply prefix to classes in a single operation
|
|
135
|
+
element.classList.add(...classes.map(cls =>
|
|
136
|
+
cls && !cls.startsWith(PREFIX_WITH_DASH) ? PREFIX_WITH_DASH + cls : cls
|
|
137
|
+
).filter(Boolean));
|
|
121
138
|
}
|
|
122
139
|
}
|
|
123
140
|
|
|
124
|
-
// Handle data attributes
|
|
125
|
-
|
|
126
|
-
element.dataset[key] =
|
|
127
|
-
}
|
|
141
|
+
// Handle data attributes directly
|
|
142
|
+
for (const key in data) {
|
|
143
|
+
element.dataset[key] = data[key];
|
|
144
|
+
}
|
|
128
145
|
|
|
129
|
-
// Handle
|
|
146
|
+
// Handle regular attributes
|
|
130
147
|
const allAttrs = { ...attrs, ...rest };
|
|
131
|
-
|
|
148
|
+
for (const key in allAttrs) {
|
|
149
|
+
const value = allAttrs[key];
|
|
132
150
|
if (value != null) element.setAttribute(key, String(value));
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
// Initialize event handler storage if not present
|
|
136
|
-
if (!element.__eventHandlers) {
|
|
137
|
-
element.__eventHandlers = {};
|
|
138
151
|
}
|
|
139
152
|
|
|
140
|
-
// Handle event forwarding
|
|
153
|
+
// Handle event forwarding
|
|
141
154
|
if (forwardEvents && (context?.emit || context?.on)) {
|
|
142
|
-
|
|
143
|
-
|
|
155
|
+
element.__eventHandlers = {};
|
|
156
|
+
|
|
157
|
+
for (const nativeEvent in forwardEvents) {
|
|
158
|
+
const eventConfig = forwardEvents[nativeEvent];
|
|
159
|
+
|
|
144
160
|
const handler = (event: Event) => {
|
|
145
|
-
// Determine if the event should be forwarded
|
|
146
161
|
let shouldForward = true;
|
|
147
162
|
|
|
148
163
|
if (typeof eventConfig === 'function') {
|
|
149
164
|
try {
|
|
150
|
-
//
|
|
151
|
-
|
|
165
|
+
// Create a lightweight context clone
|
|
166
|
+
const ctxWithElement = { ...context, element };
|
|
167
|
+
shouldForward = eventConfig(ctxWithElement, event);
|
|
152
168
|
} catch (error) {
|
|
153
169
|
console.warn(`Error in event condition for ${nativeEvent}:`, error);
|
|
154
170
|
shouldForward = false;
|
|
155
171
|
}
|
|
156
172
|
} else {
|
|
157
|
-
// If it's a boolean, use directly
|
|
158
173
|
shouldForward = Boolean(eventConfig);
|
|
159
174
|
}
|
|
160
175
|
|
|
161
|
-
// Forward the event if condition passes
|
|
162
176
|
if (shouldForward) {
|
|
163
177
|
if (context.emit) {
|
|
164
178
|
context.emit(nativeEvent, { event, element, originalEvent: event });
|
|
165
179
|
} else if (context.on) {
|
|
166
|
-
|
|
167
|
-
// Dispatch a custom event that can be listened to
|
|
168
|
-
const customEvent = new CustomEvent(nativeEvent, {
|
|
180
|
+
element.dispatchEvent(new CustomEvent(nativeEvent, {
|
|
169
181
|
detail: { event, element, originalEvent: event },
|
|
170
182
|
bubbles: true,
|
|
171
183
|
cancelable: true
|
|
172
|
-
});
|
|
173
|
-
element.dispatchEvent(customEvent);
|
|
184
|
+
}));
|
|
174
185
|
}
|
|
175
186
|
}
|
|
176
187
|
};
|
|
177
188
|
|
|
178
|
-
// Store the handler for future removal
|
|
179
189
|
element.__eventHandlers[nativeEvent] = handler;
|
|
180
|
-
|
|
181
|
-
// Add the actual event listener
|
|
182
190
|
element.addEventListener(nativeEvent, handler);
|
|
183
|
-
}
|
|
191
|
+
}
|
|
184
192
|
}
|
|
185
193
|
|
|
186
194
|
// Append to container if provided
|
|
187
|
-
if (container)
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (typeof onCreate === 'function') {
|
|
192
|
-
onCreate(element, context);
|
|
193
|
-
}
|
|
195
|
+
if (container) container.appendChild(element);
|
|
196
|
+
if (onCreate) onCreate(element, context);
|
|
194
197
|
|
|
195
198
|
return element;
|
|
196
199
|
};
|
|
@@ -200,10 +203,11 @@ export const createElement = (options: CreateElementOptions = {}): HTMLElement =
|
|
|
200
203
|
* @param element - Element to cleanup
|
|
201
204
|
*/
|
|
202
205
|
export const removeEventHandlers = (element: HTMLElement): void => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
206
|
+
const handlers = element.__eventHandlers;
|
|
207
|
+
if (handlers) {
|
|
208
|
+
for (const event in handlers) {
|
|
209
|
+
element.removeEventListener(event, handlers[event]);
|
|
210
|
+
}
|
|
207
211
|
delete element.__eventHandlers;
|
|
208
212
|
}
|
|
209
213
|
};
|
|
@@ -228,7 +232,9 @@ export const withClasses = (...classes: (string | string[])[]) =>
|
|
|
228
232
|
(element: HTMLElement): HTMLElement => {
|
|
229
233
|
const normalizedClasses = normalizeClasses(...classes);
|
|
230
234
|
if (normalizedClasses.length) {
|
|
231
|
-
element.classList.add(...normalizedClasses
|
|
235
|
+
element.classList.add(...normalizedClasses.map(cls =>
|
|
236
|
+
cls && !cls.startsWith(PREFIX_WITH_DASH) ? PREFIX_WITH_DASH + cls : cls
|
|
237
|
+
).filter(Boolean));
|
|
232
238
|
}
|
|
233
239
|
return element;
|
|
234
240
|
};
|
|
@@ -240,17 +246,17 @@ export const withClasses = (...classes: (string | string[])[]) =>
|
|
|
240
246
|
*/
|
|
241
247
|
export const withContent = (content: Node | string) =>
|
|
242
248
|
(element: HTMLElement): HTMLElement => {
|
|
243
|
-
if (content instanceof Node)
|
|
244
|
-
|
|
245
|
-
} else {
|
|
246
|
-
element.textContent = content;
|
|
247
|
-
}
|
|
249
|
+
if (content instanceof Node) element.appendChild(content);
|
|
250
|
+
else element.textContent = content;
|
|
248
251
|
return element;
|
|
249
252
|
};
|
|
250
253
|
|
|
251
254
|
// Extend HTMLElement interface to add eventHandlers property
|
|
252
255
|
declare global {
|
|
253
256
|
interface HTMLElement {
|
|
257
|
+
/**
|
|
258
|
+
* Storage for event handlers to enable cleanup
|
|
259
|
+
*/
|
|
254
260
|
__eventHandlers?: EventHandlerStorage;
|
|
255
261
|
}
|
|
256
262
|
}
|
|
@@ -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
|
}
|
|
@@ -58,6 +58,9 @@
|
|
|
58
58
|
--#{$prefix}-sys-color-on-info: #003060;
|
|
59
59
|
--#{$prefix}-sys-color-on-info-rgb: 0, 48, 96;
|
|
60
60
|
|
|
61
|
+
// Include status colors for light theme
|
|
62
|
+
@include status-colors-light();
|
|
63
|
+
|
|
61
64
|
&[data-theme-mode="dark"] {
|
|
62
65
|
// Key colors
|
|
63
66
|
--#{$prefix}-sys-color-primary: #DDB995; // Softer brown
|