mtrl 0.1.2 → 0.1.3

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/index.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  createLayout,
4
4
  createElement,
5
- createButton, createCheckbox, createTextfield, createSwitch, createContainer, createList, createSnackbar, createNavigation, createMenu
5
+ createButton, createCard, createCheckbox, createTextfield, createSwitch, createContainer, createList, createSnackbar, createNavigation, createMenu
6
6
  } from './src/index.js'
7
7
 
8
8
  export {
9
9
  createLayout,
10
- createElement, createButton, createCheckbox, createTextfield, createSwitch, createContainer, createList, createSnackbar, createNavigation, createMenu
10
+ createElement, createCard, createButton, createCheckbox, createTextfield, createSwitch, createContainer, createList, createSnackbar, createNavigation, createMenu
11
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrl",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A functional JavaScript component library with composable architecture based on Material Design 3",
5
5
  "keywords": ["component", "library", "ui", "user interface", "functional", "composable"],
6
6
  "main": "index.js",
@@ -0,0 +1,51 @@
1
+ // src/components/card/actions.js
2
+ import { PREFIX } from '../../core/config'
3
+ import { pipe } from '../../core/compose'
4
+ import { createBase, withElement } from '../../core/compose/component'
5
+
6
+ /**
7
+ * Creates a card actions component
8
+ * @param {Object} config - Actions configuration
9
+ * @param {Array<HTMLElement>} [config.actions] - Action elements to include
10
+ * @param {boolean} [config.fullBleed=false] - Whether actions extend full width
11
+ * @param {boolean} [config.vertical=false] - Whether to stack actions vertically
12
+ * @param {string} [config.align='start'] - Horizontal alignment ('start', 'center', 'end', 'space-between')
13
+ * @returns {HTMLElement} Card actions element
14
+ */
15
+ export const createCardActions = (config = {}) => {
16
+ const baseConfig = {
17
+ ...config,
18
+ componentName: 'card-actions',
19
+ prefix: PREFIX
20
+ }
21
+
22
+ try {
23
+ const actions = pipe(
24
+ createBase,
25
+ withElement({
26
+ tag: 'div',
27
+ componentName: 'card-actions',
28
+ className: [
29
+ config.class,
30
+ config.fullBleed ? `${PREFIX}-card-actions--full-bleed` : null,
31
+ config.vertical ? `${PREFIX}-card-actions--vertical` : null,
32
+ config.align ? `${PREFIX}-card-actions--${config.align}` : null
33
+ ]
34
+ })
35
+ )(baseConfig)
36
+
37
+ // Add action elements if provided
38
+ if (Array.isArray(config.actions)) {
39
+ config.actions.forEach(action => {
40
+ if (action instanceof HTMLElement) {
41
+ actions.element.appendChild(action)
42
+ }
43
+ })
44
+ }
45
+
46
+ return actions.element
47
+ } catch (error) {
48
+ console.error('Card actions creation error:', error)
49
+ throw new Error(`Failed to create card actions: ${error.message}`)
50
+ }
51
+ }
@@ -0,0 +1,102 @@
1
+ // src/components/card/api.js
2
+
3
+ /**
4
+ * Enhances a card component with API methods
5
+ * @param {Object} options - API configuration options
6
+ * @param {Object} options.lifecycle - Object containing lifecycle methods
7
+ * @returns {Function} Higher-order function that adds API methods to component
8
+ * @internal This is an internal utility for the Card component
9
+ */
10
+ export const withAPI = ({ lifecycle }) => (component) => ({
11
+ ...component,
12
+ element: component.element,
13
+
14
+ /**
15
+ * Adds content to the card
16
+ * @param {HTMLElement} contentElement - The content element to add
17
+ * @returns {Object} The card instance for chaining
18
+ */
19
+ addContent (contentElement) {
20
+ if (contentElement && contentElement.classList.contains(`${component.getClass('card')}-content`)) {
21
+ component.element.appendChild(contentElement)
22
+ }
23
+ return this
24
+ },
25
+
26
+ /**
27
+ * Sets the card header
28
+ * @param {HTMLElement} headerElement - The header element to add
29
+ * @returns {Object} The card instance for chaining
30
+ */
31
+ setHeader (headerElement) {
32
+ if (headerElement && headerElement.classList.contains(`${component.getClass('card')}-header`)) {
33
+ // Remove existing header if present
34
+ const existingHeader = component.element.querySelector(`.${component.getClass('card')}-header`)
35
+ if (existingHeader) {
36
+ existingHeader.remove()
37
+ }
38
+
39
+ // Insert at the beginning of the card
40
+ component.element.insertBefore(headerElement, component.element.firstChild)
41
+ }
42
+ return this
43
+ },
44
+
45
+ /**
46
+ * Adds media to the card
47
+ * @param {HTMLElement} mediaElement - The media element to add
48
+ * @param {string} [position='top'] - Position to place media ('top', 'bottom')
49
+ * @returns {Object} The card instance for chaining
50
+ */
51
+ addMedia (mediaElement, position = 'top') {
52
+ if (mediaElement && mediaElement.classList.contains(`${component.getClass('card')}-media`)) {
53
+ if (position === 'top') {
54
+ component.element.insertBefore(mediaElement, component.element.firstChild)
55
+ } else {
56
+ component.element.appendChild(mediaElement)
57
+ }
58
+ }
59
+ return this
60
+ },
61
+
62
+ /**
63
+ * Sets the card actions section
64
+ * @param {HTMLElement} actionsElement - The actions element to add
65
+ * @returns {Object} The card instance for chaining
66
+ */
67
+ setActions (actionsElement) {
68
+ if (actionsElement && actionsElement.classList.contains(`${component.getClass('card')}-actions`)) {
69
+ // Remove existing actions if present
70
+ const existingActions = component.element.querySelector(`.${component.getClass('card')}-actions`)
71
+ if (existingActions) {
72
+ existingActions.remove()
73
+ }
74
+
75
+ // Add actions at the end
76
+ component.element.appendChild(actionsElement)
77
+ }
78
+ return this
79
+ },
80
+
81
+ /**
82
+ * Makes the card draggable
83
+ * @param {Function} [dragStartCallback] - Callback for drag start event
84
+ * @returns {Object} The card instance for chaining
85
+ */
86
+ makeDraggable (dragStartCallback) {
87
+ component.element.setAttribute('draggable', 'true')
88
+
89
+ if (typeof dragStartCallback === 'function') {
90
+ component.element.addEventListener('dragstart', dragStartCallback)
91
+ }
92
+
93
+ return this
94
+ },
95
+
96
+ /**
97
+ * Destroys the card component and removes event listeners
98
+ */
99
+ destroy () {
100
+ lifecycle.destroy()
101
+ }
102
+ })
@@ -0,0 +1,102 @@
1
+ // src/components/card/card.js
2
+ import { PREFIX } from '../../core/config'
3
+ import { pipe } from '../../core/compose'
4
+ import { createBase, withElement } from '../../core/compose/component'
5
+ import {
6
+ withEvents,
7
+ withVariant,
8
+ withRipple,
9
+ withLifecycle
10
+ } from '../../core/compose/features'
11
+ import { withAPI } from './api'
12
+ import { CARD_VARIANTS, CARD_ELEVATIONS } from './constants'
13
+ import defaultConfig from './config'
14
+
15
+ /**
16
+ * Creates a new Card component following Material Design 3 principles
17
+ * @param {Object} config - Card configuration object
18
+ * @param {string} [config.variant='elevated'] - Card variant (elevated, filled, outlined)
19
+ * @param {boolean} [config.interactive=false] - Whether the card has hover/focus states
20
+ * @param {boolean} [config.fullWidth=false] - Whether the card spans full width of container
21
+ * @param {boolean} [config.clickable=false] - Whether the card is clickable with ripple effect
22
+ * @param {boolean} [config.draggable=false] - Whether the card is draggable
23
+ * @param {string} [config.class] - Additional CSS classes
24
+ * @returns {Object} Card component instance
25
+ */
26
+ const createCard = (config = {}) => {
27
+ const baseConfig = {
28
+ ...defaultConfig,
29
+ ...config,
30
+ componentName: 'card',
31
+ prefix: PREFIX
32
+ }
33
+
34
+ try {
35
+ const card = pipe(
36
+ createBase,
37
+ withEvents(),
38
+ withElement({
39
+ tag: 'div',
40
+ componentName: 'card',
41
+ className: [
42
+ config.class,
43
+ config.fullWidth ? `${PREFIX}-card--full-width` : null,
44
+ config.interactive ? `${PREFIX}-card--interactive` : null
45
+ ],
46
+ forwardEvents: {
47
+ click: (component) => config.clickable,
48
+ mouseenter: (component) => config.interactive,
49
+ mouseleave: (component) => config.interactive
50
+ },
51
+ interactive: config.interactive || config.clickable
52
+ }),
53
+ withVariant(baseConfig),
54
+ config.clickable ? withRipple(baseConfig) : (c) => c,
55
+ withLifecycle(),
56
+ comp => {
57
+ // Implement hover state elevation changes for interactive cards
58
+ if (comp.config.interactive) {
59
+ comp.element.addEventListener('mouseenter', () => {
60
+ if (comp.config.variant === CARD_VARIANTS.ELEVATED) {
61
+ comp.element.style.setProperty('--card-elevation', CARD_ELEVATIONS.HOVERED)
62
+ }
63
+ })
64
+
65
+ comp.element.addEventListener('mouseleave', () => {
66
+ if (comp.config.variant === CARD_VARIANTS.ELEVATED) {
67
+ comp.element.style.setProperty('--card-elevation', CARD_ELEVATIONS.RESTING)
68
+ }
69
+ })
70
+ }
71
+
72
+ // Set up draggable
73
+ if (comp.config.draggable) {
74
+ comp.element.setAttribute('draggable', 'true')
75
+ comp.element.addEventListener('dragstart', (e) => {
76
+ comp.element.style.setProperty('--card-elevation', CARD_ELEVATIONS.DRAGGED)
77
+ comp.emit('dragstart', { event: e })
78
+ })
79
+
80
+ comp.element.addEventListener('dragend', (e) => {
81
+ comp.element.style.setProperty('--card-elevation', CARD_ELEVATIONS.RESTING)
82
+ comp.emit('dragend', { event: e })
83
+ })
84
+ }
85
+
86
+ return comp
87
+ },
88
+ comp => withAPI({
89
+ lifecycle: {
90
+ destroy: () => comp.lifecycle.destroy()
91
+ }
92
+ })(comp)
93
+ )(baseConfig)
94
+
95
+ return card
96
+ } catch (error) {
97
+ console.error('Card creation error:', error)
98
+ throw new Error(`Failed to create card: ${error.message}`)
99
+ }
100
+ }
101
+
102
+ export default createCard
@@ -0,0 +1,16 @@
1
+ // src/components/card/config.js
2
+
3
+ import { PREFIX } from '../../core/config'
4
+ import { CARD_VARIANTS } from './constants'
5
+
6
+ const defaultConfig = {
7
+ componentName: 'card',
8
+ prefix: PREFIX,
9
+ variant: CARD_VARIANTS.ELEVATED,
10
+ interactive: false,
11
+ fullWidth: false,
12
+ clickable: false,
13
+ draggable: false
14
+ }
15
+
16
+ export default defaultConfig
@@ -0,0 +1,68 @@
1
+ // src/components/card/constants.js
2
+
3
+ /**
4
+ * Card variant types following Material Design 3
5
+ * @enum {string}
6
+ */
7
+ export const CARD_VARIANTS = {
8
+ ELEVATED: 'elevated',
9
+ FILLED: 'filled',
10
+ OUTLINED: 'outlined'
11
+ }
12
+
13
+ /**
14
+ * Card elevation levels
15
+ * @enum {number}
16
+ */
17
+ export const CARD_ELEVATIONS = {
18
+ RESTING: 1,
19
+ HOVERED: 2,
20
+ DRAGGED: 4
21
+ }
22
+
23
+ /**
24
+ * Validation schema for card configuration
25
+ */
26
+ export const CARD_SCHEMA = {
27
+ variant: {
28
+ type: 'string',
29
+ enum: Object.values(CARD_VARIANTS),
30
+ default: CARD_VARIANTS.ELEVATED
31
+ },
32
+ interactive: {
33
+ type: 'boolean',
34
+ default: false
35
+ },
36
+ fullWidth: {
37
+ type: 'boolean',
38
+ default: false
39
+ },
40
+ clickable: {
41
+ type: 'boolean',
42
+ default: false
43
+ },
44
+ draggable: {
45
+ type: 'boolean',
46
+ default: false
47
+ },
48
+ class: {
49
+ type: 'string',
50
+ required: false
51
+ },
52
+ headerConfig: {
53
+ type: 'object',
54
+ required: false
55
+ },
56
+ contentConfig: {
57
+ type: 'object',
58
+ required: false
59
+ },
60
+ actionsConfig: {
61
+ type: 'object',
62
+ required: false
63
+ },
64
+ mediaConfig: {
65
+ type: 'object',
66
+ required: false
67
+ }
68
+ }
@@ -0,0 +1,51 @@
1
+ // src/components/card/content.js
2
+ import { PREFIX } from '../../core/config'
3
+ import { pipe } from '../../core/compose'
4
+ import { createBase, withElement } from '../../core/compose/component'
5
+
6
+ /**
7
+ * Creates a card content component
8
+ * @param {Object} config - Content configuration
9
+ * @param {string} [config.text] - Text content
10
+ * @param {string} [config.html] - HTML content
11
+ * @param {Array<HTMLElement>} [config.children] - Child elements to append
12
+ * @param {boolean} [config.padding=true] - Whether to apply default padding
13
+ * @returns {HTMLElement} Card content element
14
+ */
15
+ export const createCardContent = (config = {}) => {
16
+ const baseConfig = {
17
+ ...config,
18
+ componentName: 'card-content',
19
+ prefix: PREFIX
20
+ }
21
+
22
+ try {
23
+ const content = pipe(
24
+ createBase,
25
+ withElement({
26
+ tag: 'div',
27
+ componentName: 'card-content',
28
+ className: [
29
+ config.class,
30
+ config.padding === false ? `${PREFIX}-card-content--no-padding` : null
31
+ ],
32
+ html: config.html,
33
+ text: config.text
34
+ })
35
+ )(baseConfig)
36
+
37
+ // Add children if provided
38
+ if (Array.isArray(config.children)) {
39
+ config.children.forEach(child => {
40
+ if (child instanceof HTMLElement) {
41
+ content.element.appendChild(child)
42
+ }
43
+ })
44
+ }
45
+
46
+ return content.element
47
+ } catch (error) {
48
+ console.error('Card content creation error:', error)
49
+ throw new Error(`Failed to create card content: ${error.message}`)
50
+ }
51
+ }
@@ -0,0 +1,218 @@
1
+ // src/components/card/features.js
2
+ import { PREFIX } from '../../core/config'
3
+ import { createElement } from '../../core/dom/create'
4
+
5
+ /**
6
+ * Higher-order function to add loading state to a card
7
+ * @param {Object} config - Loading state configuration
8
+ * @param {boolean} [config.initialState=false] - Initial loading state
9
+ * @returns {Function} Card component enhancer
10
+ */
11
+ export const withLoading = (config = {}) => (component) => {
12
+ const initialState = config.initialState || false
13
+ let loadingElement = null
14
+ let isLoading = initialState
15
+
16
+ if (initialState) {
17
+ setLoading(true)
18
+ }
19
+
20
+ function setLoading (loading) {
21
+ isLoading = loading
22
+
23
+ if (loading && !loadingElement) {
24
+ // Create and add loading overlay
25
+ loadingElement = createElement({
26
+ tag: 'div',
27
+ className: `${PREFIX}-card-loading-overlay`,
28
+ container: component.element
29
+ })
30
+
31
+ // Add spinner
32
+ createElement({
33
+ tag: 'div',
34
+ className: `${PREFIX}-card-loading-spinner`,
35
+ container: loadingElement
36
+ })
37
+
38
+ component.element.classList.add(`${PREFIX}-card--state-loading`)
39
+ } else if (!loading && loadingElement) {
40
+ // Remove loading overlay
41
+ loadingElement.remove()
42
+ loadingElement = null
43
+ component.element.classList.remove(`${PREFIX}-card--state-loading`)
44
+ }
45
+ }
46
+
47
+ return {
48
+ ...component,
49
+ loading: {
50
+ isLoading: () => isLoading,
51
+ setLoading
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Higher-order function to add expandable behavior to a card
58
+ * @param {Object} config - Expandable configuration
59
+ * @param {boolean} [config.initialExpanded=false] - Whether card is initially expanded
60
+ * @param {HTMLElement} [config.expandableContent] - Content to show when expanded
61
+ * @returns {Function} Card component enhancer
62
+ */
63
+ export const withExpandable = (config = {}) => (component) => {
64
+ const initialExpanded = config.initialExpanded || false
65
+ let isExpanded = initialExpanded
66
+ const expandableContent = config.expandableContent
67
+ let expandButton = null
68
+
69
+ // Create expand/collapse button
70
+ expandButton = createElement({
71
+ tag: 'button',
72
+ className: `${PREFIX}-card-expand-button`,
73
+ attrs: {
74
+ 'aria-expanded': isExpanded ? 'true' : 'false',
75
+ 'aria-label': isExpanded ? 'Collapse' : 'Expand'
76
+ }
77
+ })
78
+
79
+ // Add to card as action if not already present
80
+ const actionsContainer = component.element.querySelector(`.${PREFIX}-card-actions`)
81
+ if (actionsContainer) {
82
+ actionsContainer.appendChild(expandButton)
83
+ } else {
84
+ // Create actions container if not present
85
+ const newActionsContainer = createElement({
86
+ tag: 'div',
87
+ className: `${PREFIX}-card-actions`,
88
+ container: component.element
89
+ })
90
+ newActionsContainer.appendChild(expandButton)
91
+ }
92
+
93
+ // Set initial state
94
+ if (expandableContent) {
95
+ expandableContent.classList.add(`${PREFIX}-card-expandable-content`)
96
+ if (!initialExpanded) {
97
+ expandableContent.style.display = 'none'
98
+ }
99
+ component.element.appendChild(expandableContent)
100
+ }
101
+
102
+ // Toggle expanded state
103
+ function toggleExpanded () {
104
+ setExpanded(!isExpanded)
105
+ }
106
+
107
+ // Set expanded state
108
+ function setExpanded (expanded) {
109
+ isExpanded = expanded
110
+
111
+ if (expandableContent) {
112
+ expandableContent.style.display = expanded ? 'block' : 'none'
113
+ }
114
+
115
+ expandButton.setAttribute('aria-expanded', expanded ? 'true' : 'false')
116
+ expandButton.setAttribute('aria-label', expanded ? 'Collapse' : 'Expand')
117
+
118
+ if (expanded) {
119
+ component.element.classList.add(`${PREFIX}-card--expanded`)
120
+ } else {
121
+ component.element.classList.remove(`${PREFIX}-card--expanded`)
122
+ }
123
+
124
+ component.emit('expandedChanged', { expanded })
125
+ }
126
+
127
+ // Add click handler to toggle button
128
+ expandButton.addEventListener('click', (e) => {
129
+ e.stopPropagation()
130
+ toggleExpanded()
131
+ })
132
+
133
+ return {
134
+ ...component,
135
+ expandable: {
136
+ isExpanded: () => isExpanded,
137
+ setExpanded,
138
+ toggleExpanded
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Higher-order function to add swipeable behavior to a card
145
+ * @param {Object} config - Swipeable configuration
146
+ * @param {Function} [config.onSwipeLeft] - Callback when card is swiped left
147
+ * @param {Function} [config.onSwipeRight] - Callback when card is swiped right
148
+ * @param {number} [config.threshold=100] - Swipe distance threshold to trigger action
149
+ * @returns {Function} Card component enhancer
150
+ */
151
+ export const withSwipeable = (config = {}) => (component) => {
152
+ const threshold = config.threshold || 100
153
+ let startX = 0
154
+ let currentX = 0
155
+
156
+ function handleTouchStart (e) {
157
+ startX = e.touches[0].clientX
158
+ component.element.style.transition = 'none'
159
+ }
160
+
161
+ function handleTouchMove (e) {
162
+ if (!startX) return
163
+
164
+ currentX = e.touches[0].clientX
165
+ const diffX = currentX - startX
166
+
167
+ // Apply transform to move card
168
+ component.element.style.transform = `translateX(${diffX}px)`
169
+ }
170
+
171
+ function handleTouchEnd () {
172
+ if (!startX) return
173
+
174
+ component.element.style.transition = 'transform 0.3s ease'
175
+ const diffX = currentX - startX
176
+
177
+ if (Math.abs(diffX) >= threshold) {
178
+ // Swipe threshold reached
179
+ if (diffX > 0 && config.onSwipeRight) {
180
+ // Swipe right
181
+ component.element.style.transform = 'translateX(100%)'
182
+ config.onSwipeRight(component)
183
+ } else if (diffX < 0 && config.onSwipeLeft) {
184
+ // Swipe left
185
+ component.element.style.transform = 'translateX(-100%)'
186
+ config.onSwipeLeft(component)
187
+ } else {
188
+ // Reset if no handler
189
+ component.element.style.transform = 'translateX(0)'
190
+ }
191
+ } else {
192
+ // Reset if below threshold
193
+ component.element.style.transform = 'translateX(0)'
194
+ }
195
+
196
+ startX = 0
197
+ currentX = 0
198
+ }
199
+
200
+ // Add event listeners
201
+ component.element.addEventListener('touchstart', handleTouchStart)
202
+ component.element.addEventListener('touchmove', handleTouchMove)
203
+ component.element.addEventListener('touchend', handleTouchEnd)
204
+
205
+ // Add swipeable class
206
+ component.element.classList.add(`${PREFIX}-card--swipeable`)
207
+
208
+ // Return enhanced component
209
+ return {
210
+ ...component,
211
+ swipeable: {
212
+ reset: () => {
213
+ component.element.style.transition = 'transform 0.3s ease'
214
+ component.element.style.transform = 'translateX(0)'
215
+ }
216
+ }
217
+ }
218
+ }
@@ -0,0 +1,92 @@
1
+ // src/components/card/header.js
2
+ import { PREFIX } from '../../core/config'
3
+ import { pipe } from '../../core/compose'
4
+ import { createBase, withElement } from '../../core/compose/component'
5
+ import { withText } from '../../core/compose/features'
6
+ import { createElement } from '../../core/dom/create'
7
+
8
+ /**
9
+ * Creates a card header component
10
+ * @param {Object} config - Header configuration
11
+ * @param {string} [config.title] - Title text
12
+ * @param {string} [config.subtitle] - Subtitle text
13
+ * @param {HTMLElement|string} [config.avatar] - Avatar element or HTML string
14
+ * @param {HTMLElement|string} [config.action] - Action element or HTML string
15
+ * @returns {HTMLElement} Card header element
16
+ */
17
+ export const createCardHeader = (config = {}) => {
18
+ const baseConfig = {
19
+ ...config,
20
+ componentName: 'card-header',
21
+ prefix: PREFIX
22
+ }
23
+
24
+ try {
25
+ const header = pipe(
26
+ createBase,
27
+ withElement({
28
+ tag: 'div',
29
+ componentName: 'card-header',
30
+ className: config.class
31
+ })
32
+ )(baseConfig)
33
+
34
+ // Create text container
35
+ const textContainer = createElement({
36
+ tag: 'div',
37
+ className: `${PREFIX}-card-header-text`,
38
+ container: header.element
39
+ })
40
+
41
+ // Add title if provided
42
+ if (config.title) {
43
+ createElement({
44
+ tag: 'h3',
45
+ className: `${PREFIX}-card-header-title`,
46
+ text: config.title,
47
+ container: textContainer
48
+ })
49
+ }
50
+
51
+ // Add subtitle if provided
52
+ if (config.subtitle) {
53
+ createElement({
54
+ tag: 'h4',
55
+ className: `${PREFIX}-card-header-subtitle`,
56
+ text: config.subtitle,
57
+ container: textContainer
58
+ })
59
+ }
60
+
61
+ // Add avatar if provided
62
+ if (config.avatar) {
63
+ const avatarElement = typeof config.avatar === 'string'
64
+ ? createElement({
65
+ tag: 'div',
66
+ className: `${PREFIX}-card-header-avatar`,
67
+ html: config.avatar
68
+ })
69
+ : config.avatar
70
+
71
+ header.element.insertBefore(avatarElement, header.element.firstChild)
72
+ }
73
+
74
+ // Add action if provided
75
+ if (config.action) {
76
+ const actionElement = typeof config.action === 'string'
77
+ ? createElement({
78
+ tag: 'div',
79
+ className: `${PREFIX}-card-header-action`,
80
+ html: config.action
81
+ })
82
+ : config.action
83
+
84
+ header.element.appendChild(actionElement)
85
+ }
86
+
87
+ return header.element
88
+ } catch (error) {
89
+ console.error('Card header creation error:', error)
90
+ throw new Error(`Failed to create card header: ${error.message}`)
91
+ }
92
+ }
@@ -0,0 +1,7 @@
1
+ // src/components/card/index.js
2
+ export { default } from './card.js'
3
+ export { createCardContent } from './content.js'
4
+ export { createCardHeader } from './header.js'
5
+ export { createCardActions } from './actions.js'
6
+ export { createCardMedia } from './media.js'
7
+ export { CARD_VARIANTS, CARD_ELEVATIONS } from './constants.js'
@@ -0,0 +1,56 @@
1
+ // src/components/card/media.js
2
+ import { PREFIX } from '../../core/config'
3
+ import { pipe } from '../../core/compose'
4
+ import { createBase, withElement } from '../../core/compose/component'
5
+
6
+ /**
7
+ * Creates a card media component
8
+ * @param {Object} config - Media configuration
9
+ * @param {string} [config.src] - Image source URL
10
+ * @param {string} [config.alt] - Image alt text
11
+ * @param {HTMLElement} [config.element] - Custom media element
12
+ * @param {string} [config.aspectRatio='16:9'] - Media aspect ratio
13
+ * @param {boolean} [config.contain=false] - Whether to use object-fit: contain
14
+ * @returns {HTMLElement} Card media element
15
+ */
16
+ export const createCardMedia = (config = {}) => {
17
+ const baseConfig = {
18
+ ...config,
19
+ componentName: 'card-media',
20
+ prefix: PREFIX
21
+ }
22
+
23
+ try {
24
+ const media = pipe(
25
+ createBase,
26
+ withElement({
27
+ tag: 'div',
28
+ componentName: 'card-media',
29
+ className: [
30
+ config.class,
31
+ config.aspectRatio ? `${PREFIX}-card-media--${config.aspectRatio.replace(':', '-')}` : null,
32
+ config.contain ? `${PREFIX}-card-media--contain` : null
33
+ ]
34
+ })
35
+ )(baseConfig)
36
+
37
+ // If custom element is provided, use it
38
+ if (config.element instanceof HTMLElement) {
39
+ media.element.appendChild(config.element)
40
+ }
41
+
42
+ // Otherwise create an image if src is provided
43
+ else if (config.src) {
44
+ const img = document.createElement('img')
45
+ img.src = config.src
46
+ if (config.alt) img.alt = config.alt
47
+ img.className = `${PREFIX}-card-media-img`
48
+ media.element.appendChild(img)
49
+ }
50
+
51
+ return media.element
52
+ } catch (error) {
53
+ console.error('Card media creation error:', error)
54
+ throw new Error(`Failed to create card media: ${error.message}`)
55
+ }
56
+ }
@@ -0,0 +1,287 @@
1
+ // src/components/card/styles.scss
2
+ @use '../../styles/abstract/base' as base;
3
+ @use '../../styles/abstract/variables' as v;
4
+ @use '../../styles/abstract/functions' as f;
5
+ @use '../../styles/abstract/mixins' as m;
6
+ @use '../../styles/abstract/theme' as t;
7
+
8
+ $component: '#{base.$prefix}-card';
9
+
10
+ .#{$component} {
11
+ // Base styles
12
+ position: relative;
13
+ display: flex;
14
+ flex-direction: column;
15
+ box-sizing: border-box;
16
+ border-radius: v.shape('medium');
17
+ background-color: t.color('surface');
18
+ color: t.color('on-surface');
19
+ overflow: hidden;
20
+ // width: v.card('width');
21
+ width: 340px;
22
+ --card-elevation: m.elevation(2);
23
+
24
+ // Typography
25
+ @include m.typography('body-medium');
26
+
27
+ // Transition for elevation and hover states
28
+ @include m.motion-transition(
29
+ box-shadow,
30
+ background-color,
31
+ border-color
32
+ );
33
+
34
+ // Ripple styles for clickable cards
35
+ .ripple {
36
+ position: absolute;
37
+ border-radius: 50%;
38
+ transform: scale(0);
39
+ pointer-events: none;
40
+ background-color: currentColor;
41
+ opacity: 0.08;
42
+ }
43
+
44
+ // Ensure proper stacking for inner components
45
+ > :not(:last-child) {
46
+ margin-bottom: 0;
47
+ }
48
+
49
+ // === Variants ===
50
+
51
+ // Elevated variant
52
+ &--elevated {
53
+ // @include m.elevation(m.elevation(2));
54
+
55
+ &:hover {
56
+ --card-elevation: 2;
57
+ }
58
+ }
59
+
60
+ // Filled variant
61
+ &--filled {
62
+ background-color: t.color('surface-container-highest');
63
+
64
+ &:hover.#{$component}--interactive {
65
+ @include m.state-layer(t.color('on-surface'), 'hover');
66
+ }
67
+ }
68
+
69
+ // Outlined variant
70
+ &--outlined {
71
+ border: 1px solid t.color('outline');
72
+ background-color: t.color('surface');
73
+
74
+ &:hover.#{$component}--interactive {
75
+ @include m.state-layer(t.color('on-surface'), 'hover');
76
+ border-color: t.color('outline-variant');
77
+ }
78
+ }
79
+
80
+ // === Modifiers ===
81
+
82
+ // Interactive cards
83
+ &--interactive {
84
+ cursor: pointer;
85
+ user-select: none;
86
+ }
87
+
88
+ // Full-width cards
89
+ &--full-width {
90
+ width: 100%;
91
+ }
92
+
93
+ // === Sub-components ===
94
+
95
+ // Card Header
96
+ &-header {
97
+ display: flex;
98
+ align-items: center;
99
+ padding: 16px;
100
+
101
+ &-avatar {
102
+ margin-right: 16px;
103
+ flex-shrink: 0;
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+
108
+ img {
109
+ width: 40px;
110
+ height: 40px;
111
+ border-radius: 50%;
112
+ object-fit: cover;
113
+ }
114
+ }
115
+
116
+ &-text {
117
+ flex: 1;
118
+ overflow: hidden;
119
+ }
120
+
121
+ &-title {
122
+ margin: 0;
123
+ @include m.typography('title-medium');
124
+ @include m.truncate;
125
+ color: t.color('on-surface');
126
+ }
127
+
128
+ &-subtitle {
129
+ margin: 0;
130
+ @include m.typography('body-medium');
131
+ @include m.truncate;
132
+ color: t.color('on-surface-variant');
133
+ }
134
+
135
+ &-action {
136
+ margin-left: 8px;
137
+ flex-shrink: 0;
138
+ }
139
+ }
140
+
141
+ // Card Media
142
+ &-media {
143
+ position: relative;
144
+ overflow: hidden;
145
+
146
+ &-img {
147
+ display: block;
148
+ width: 100%;
149
+ object-fit: cover;
150
+ }
151
+
152
+ // Aspect ratios
153
+ &--16-9 {
154
+ aspect-ratio: 16 / 9;
155
+
156
+ img {
157
+ height: 100%;
158
+ }
159
+ }
160
+
161
+ &--4-3 {
162
+ aspect-ratio: 4 / 3;
163
+
164
+ img {
165
+ height: 100%;
166
+ }
167
+ }
168
+
169
+ &--1-1 {
170
+ aspect-ratio: 1 / 1;
171
+
172
+ img {
173
+ height: 100%;
174
+ }
175
+ }
176
+
177
+ &--contain {
178
+ img {
179
+ object-fit: contain;
180
+ }
181
+ }
182
+ }
183
+
184
+ // Card Content
185
+ &-content {
186
+ padding: 16px;
187
+ flex: 1 1 auto;
188
+
189
+ > *:first-child {
190
+ margin-top: 0;
191
+ }
192
+
193
+ > *:last-child {
194
+ margin-bottom: 0;
195
+ }
196
+
197
+ // When content follows media without padding
198
+ .#{$component}-media + &:not(.#{$component}-content--no-padding) {
199
+ padding-top: 16px;
200
+ }
201
+
202
+ // No padding modifier
203
+ &--no-padding {
204
+ padding: 0;
205
+ }
206
+ }
207
+
208
+ // Card Actions
209
+ &-actions {
210
+ display: flex;
211
+ flex-wrap: wrap;
212
+ padding: 8px;
213
+ align-items: center;
214
+
215
+ > * {
216
+ margin: 0 4px;
217
+
218
+ &:first-child {
219
+ margin-left: 8px;
220
+ }
221
+
222
+ &:last-child {
223
+ margin-right: 8px;
224
+ }
225
+ }
226
+
227
+ // Full-bleed actions
228
+ &--full-bleed {
229
+ padding: 0;
230
+
231
+ > * {
232
+ margin: 0;
233
+ border-radius: 0;
234
+ flex: 1 1 auto;
235
+
236
+ &:first-child {
237
+ margin-left: 0;
238
+ }
239
+
240
+ &:last-child {
241
+ margin-right: 0;
242
+ }
243
+ }
244
+ }
245
+
246
+ // Vertical actions
247
+ &--vertical {
248
+ flex-direction: column;
249
+
250
+ > * {
251
+ width: 100%;
252
+ margin: 4px 0;
253
+
254
+ &:first-child {
255
+ margin-top: 8px;
256
+ }
257
+
258
+ &:last-child {
259
+ margin-bottom: 8px;
260
+ }
261
+ }
262
+ }
263
+
264
+ // Alignment variations
265
+ &--center {
266
+ justify-content: center;
267
+ }
268
+
269
+ &--end {
270
+ justify-content: flex-end;
271
+ }
272
+
273
+ &--space-between {
274
+ justify-content: space-between;
275
+ }
276
+ }
277
+
278
+ // State classes
279
+ &--state-disabled {
280
+ opacity: 0.38;
281
+ pointer-events: none;
282
+ }
283
+
284
+ &--state-loading {
285
+ pointer-events: none;
286
+ }
287
+ }
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  export { createElement } from './core/dom/create'
3
3
  export { default as createLayout } from './core/layout'
4
4
  export { default as createButton } from './components/button'
5
+ export { default as createCard } from './components/card'
5
6
  export { default as createCheckbox } from './components/checkbox'
6
7
  export { default as createContainer } from './components/container'
7
8
  export { default as createMenu } from './components/menu'