mtrl 0.1.0 → 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 +2 -2
- package/package.json +1 -1
- package/src/components/card/actions.js +51 -0
- package/src/components/card/api.js +102 -0
- package/src/components/card/card.js +102 -0
- package/src/components/card/config.js +16 -0
- package/src/components/card/constants.js +68 -0
- package/src/components/card/content.js +51 -0
- package/src/components/card/features.js +218 -0
- package/src/components/card/header.js +92 -0
- package/src/components/card/index.js +7 -0
- package/src/components/card/media.js +56 -0
- package/src/components/card/styles.scss +287 -0
- package/src/core/layout/index.js +3 -1
- package/src/index.js +1 -0
- package/src/styles/themes/_autumn.scss +81 -0
- package/src/styles/themes/_forest.scss +46 -46
- package/src/styles/themes/_spring.scss +71 -0
- package/src/styles/themes/_summer.scss +82 -0
- package/src/styles/themes/_winter.scss +71 -0
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.
|
|
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
|
+
}
|