@mhmo91/schmancy 0.9.6 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -3
- package/mixins/baseElement.ts +0 -190
- package/mixins/constructor.ts +0 -3
- package/mixins/discovery.service.ts +0 -221
- package/mixins/formField.mixin.ts +0 -255
- package/mixins/index.ts +0 -7
- package/mixins/litElement.mixin.ts +0 -15
- package/mixins/scss.d.ts +0 -21
- package/mixins/surface.mixin.ts +0 -93
- package/mixins/tailwind.css +0 -560
- package/mixins/tailwind.mixin.ts +0 -30
package/package.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mhmo91/schmancy",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"description": "UI library build with web components",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"customElements": "custom-elements.json",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": {
|
|
9
9
|
"types": "./types/src/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.cjs",
|
|
10
12
|
"default": "./dist/index.js"
|
|
11
13
|
},
|
|
12
14
|
"./mixins": {
|
|
13
15
|
"types": "./types/mixins/index.d.ts",
|
|
14
|
-
"
|
|
16
|
+
"import": "./dist/mixins.js",
|
|
17
|
+
"require": "./dist/mixins.cjs",
|
|
18
|
+
"default": "./dist/mixins.js"
|
|
15
19
|
},
|
|
16
20
|
"./*": {
|
|
17
21
|
"types": "./types/src/*/index.d.ts",
|
|
@@ -35,7 +39,6 @@
|
|
|
35
39
|
"dist",
|
|
36
40
|
"types",
|
|
37
41
|
"src",
|
|
38
|
-
"mixins",
|
|
39
42
|
".claude-plugin",
|
|
40
43
|
"skills",
|
|
41
44
|
"custom-elements.json",
|
package/mixins/baseElement.ts
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import type { Constructor } from './constructor'
|
|
2
|
-
import { LitElement } from 'lit'
|
|
3
|
-
import { Subject, fromEvent, Observable } from 'rxjs'
|
|
4
|
-
import { takeUntil } from 'rxjs/operators'
|
|
5
|
-
import { classMap } from 'lit/directives/class-map.js'
|
|
6
|
-
import { styleMap } from 'lit/directives/style-map.js'
|
|
7
|
-
import { discoverComponent, DISCOVER_EVENT, DISCOVER_RESPONSE_EVENT, type DiscoverRequest } from './discovery.service'
|
|
8
|
-
import { consume } from '@lit/context'
|
|
9
|
-
import { themeContext } from '../src/theme/context'
|
|
10
|
-
import type { TSchmancyTheme } from '../src/theme/theme.interface'
|
|
11
|
-
|
|
12
|
-
export declare class IBaseMixin {
|
|
13
|
-
disconnecting: Subject<boolean>
|
|
14
|
-
classMap: typeof classMap
|
|
15
|
-
styleMap: typeof styleMap
|
|
16
|
-
discover<T extends HTMLElement>(tag: string): Observable<T | null>
|
|
17
|
-
readonly stableId: string
|
|
18
|
-
uid: string
|
|
19
|
-
/**
|
|
20
|
-
* Current locale from theme context. Use with Intl.NumberFormat/DateTimeFormat.
|
|
21
|
-
* Defaults to navigator.language if no theme provider is found.
|
|
22
|
-
* @example new Intl.NumberFormat(this.locale).format(1234.56)
|
|
23
|
-
*/
|
|
24
|
-
readonly locale: string
|
|
25
|
-
dispatchScopedEvent<T>(eventName: string, detail?: T, options?: { bubbles?: boolean; composed?: boolean }): void
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const BaseElement = <T extends Constructor<LitElement>>(superClass: T) => {
|
|
29
|
-
class BaseElement extends superClass {
|
|
30
|
-
disconnecting = new Subject<boolean>()
|
|
31
|
-
private _stableId?: string
|
|
32
|
-
private _uid?: string
|
|
33
|
-
|
|
34
|
-
@consume({ context: themeContext, subscribe: true })
|
|
35
|
-
private _theme?: Partial<TSchmancyTheme>
|
|
36
|
-
|
|
37
|
-
/** Current locale from theme context. Falls back to navigator.language. */
|
|
38
|
-
get locale(): string {
|
|
39
|
-
return this._theme?.locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'de-DE')
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Stable ID from DOM path - lazy, only computed on first access */
|
|
43
|
-
get stableId(): string {
|
|
44
|
-
if (this._stableId) return this._stableId
|
|
45
|
-
const path: string[] = []
|
|
46
|
-
for (let el: Element | null = this; el?.parentElement && path.length < 5; el = el.parentElement) {
|
|
47
|
-
const tag = el.tagName.toLowerCase()
|
|
48
|
-
const siblings = Array.from(el.parentElement.children).filter(c => c.tagName === el!.tagName)
|
|
49
|
-
path.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(el) + 1})` : tag)
|
|
50
|
-
}
|
|
51
|
-
const hash = Array.from(path.join('>')).reduce((h, c) => Math.imul(31, h) + c.charCodeAt(0) | 0, 0)
|
|
52
|
-
return this._stableId = `el-${Math.abs(hash).toString(36)}`
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Unique instance ID - can be overridden via attribute, otherwise auto-generated.
|
|
57
|
-
* Usage: <my-component uid="custom-id"> or let it auto-generate
|
|
58
|
-
*/
|
|
59
|
-
get uid(): string {
|
|
60
|
-
// Check if uid was set via attribute
|
|
61
|
-
const attrUid = this.getAttribute('uid')
|
|
62
|
-
if (attrUid) return attrUid
|
|
63
|
-
|
|
64
|
-
// Auto-generate if not set
|
|
65
|
-
if (!this._uid) {
|
|
66
|
-
this._uid = `el-${crypto.randomUUID()}`
|
|
67
|
-
}
|
|
68
|
-
return this._uid
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
set uid(value: string) {
|
|
72
|
-
if (value) {
|
|
73
|
-
this.setAttribute('uid', value)
|
|
74
|
-
} else {
|
|
75
|
-
this.removeAttribute('uid')
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Dispatch an event scoped to this component instance.
|
|
81
|
-
* Emits BOTH scoped event (eventName::uid) and generic event for backward compatibility.
|
|
82
|
-
* This prevents event collision between multiple instances of the same component.
|
|
83
|
-
*/
|
|
84
|
-
dispatchScopedEvent<T>(eventName: string, detail?: T, options: { bubbles?: boolean; composed?: boolean } = {}): void {
|
|
85
|
-
const { bubbles = false, composed = true } = options
|
|
86
|
-
|
|
87
|
-
// Emit scoped event for new code
|
|
88
|
-
this.dispatchEvent(
|
|
89
|
-
new CustomEvent(`${eventName}::${this.uid}`, {
|
|
90
|
-
detail,
|
|
91
|
-
bubbles,
|
|
92
|
-
composed,
|
|
93
|
-
})
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
// Emit generic event for backward compatibility
|
|
97
|
-
this.dispatchEvent(
|
|
98
|
-
new CustomEvent(eventName, {
|
|
99
|
-
detail,
|
|
100
|
-
bubbles,
|
|
101
|
-
composed,
|
|
102
|
-
})
|
|
103
|
-
)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
classMap(classes: Record<string, boolean>) {
|
|
107
|
-
const newClasses: Record<string, boolean> = {}
|
|
108
|
-
Object.keys(classes).forEach(key => {
|
|
109
|
-
key
|
|
110
|
-
.trim()
|
|
111
|
-
.split(' ')
|
|
112
|
-
.filter(Boolean)
|
|
113
|
-
.forEach(k => {
|
|
114
|
-
newClasses[k] = classes[key]
|
|
115
|
-
})
|
|
116
|
-
})
|
|
117
|
-
return classMap(newClasses)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
styleMap(styles: Record<string, string | number>) {
|
|
121
|
-
return styleMap(styles)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
connectedCallback() {
|
|
125
|
-
super.connectedCallback()
|
|
126
|
-
this.setupDiscoveryResponse()
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
private setupDiscoveryResponse() {
|
|
130
|
-
const tagName = this.tagName.toLowerCase()
|
|
131
|
-
const whereAreYouEvent = `${tagName}-where-are-you`
|
|
132
|
-
const hereIAmEvent = `${tagName}-here-i-am`
|
|
133
|
-
|
|
134
|
-
// 1. Component tag discovery (e.g., 'schmancy-fancy-where-are-you')
|
|
135
|
-
fromEvent(window, whereAreYouEvent)
|
|
136
|
-
.pipe(takeUntil(this.disconnecting))
|
|
137
|
-
.subscribe(() => {
|
|
138
|
-
window.dispatchEvent(
|
|
139
|
-
new CustomEvent(hereIAmEvent, {
|
|
140
|
-
detail: { component: this },
|
|
141
|
-
bubbles: true,
|
|
142
|
-
composed: true,
|
|
143
|
-
}),
|
|
144
|
-
)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
// 2. CSS selector discovery (e.g., '#app-card', '.my-class', '[uid="xyz"]')
|
|
148
|
-
fromEvent<CustomEvent<DiscoverRequest>>(window, DISCOVER_EVENT)
|
|
149
|
-
.pipe(takeUntil(this.disconnecting))
|
|
150
|
-
.subscribe(({ detail: { selector, requestId } }) => {
|
|
151
|
-
let found: Element | null = null
|
|
152
|
-
|
|
153
|
-
// Check if selector matches this component's id or uid
|
|
154
|
-
if (selector.startsWith('#')) {
|
|
155
|
-
const id = selector.slice(1)
|
|
156
|
-
if (this.id === id || this.uid === id) {
|
|
157
|
-
found = this
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Check our shadow DOM for matching element
|
|
162
|
-
if (!found && this.shadowRoot) {
|
|
163
|
-
found = this.shadowRoot.querySelector(selector)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (found) {
|
|
167
|
-
window.dispatchEvent(
|
|
168
|
-
new CustomEvent(DISCOVER_RESPONSE_EVENT, {
|
|
169
|
-
detail: { requestId, element: found },
|
|
170
|
-
bubbles: true,
|
|
171
|
-
composed: true,
|
|
172
|
-
}),
|
|
173
|
-
)
|
|
174
|
-
}
|
|
175
|
-
})
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Make discover public to match the interface
|
|
179
|
-
discover<T extends HTMLElement>(tag: string): Observable<T | null> {
|
|
180
|
-
return discoverComponent<T>(tag)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
disconnectedCallback() {
|
|
184
|
-
this.disconnecting.next(true)
|
|
185
|
-
this.disconnecting.complete()
|
|
186
|
-
super.disconnectedCallback()
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
return BaseElement as Constructor<IBaseMixin> & T
|
|
190
|
-
}
|
package/mixins/constructor.ts
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import { fromEvent, timer, race, Observable } from 'rxjs'
|
|
2
|
-
import { takeUntil, map, defaultIfEmpty, take } from 'rxjs/operators'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Global discovery event names
|
|
6
|
-
*/
|
|
7
|
-
const DISCOVER_EVENT = 'schmancy-discover'
|
|
8
|
-
const DISCOVER_RESPONSE_EVENT = 'schmancy-discover-response'
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Discovery request detail
|
|
12
|
-
*/
|
|
13
|
-
interface DiscoverRequest {
|
|
14
|
-
selector: string
|
|
15
|
-
requestId: string
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Discovery response detail
|
|
20
|
-
*/
|
|
21
|
-
interface DiscoverResponse {
|
|
22
|
-
requestId: string
|
|
23
|
-
element: HTMLElement
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Discover a component in the DOM using the WhereAreYou/HereIAm pattern.
|
|
28
|
-
*
|
|
29
|
-
* @param componentTag - The tag name of the component to discover (e.g., 'schmancy-navigation-rail')
|
|
30
|
-
* @param timeout - How long to wait for a response in milliseconds (default: 100)
|
|
31
|
-
* @returns Observable that emits the discovered component or null if not found
|
|
32
|
-
*/
|
|
33
|
-
export function discoverComponent<T extends HTMLElement>(
|
|
34
|
-
componentTag: string,
|
|
35
|
-
timeout = 100,
|
|
36
|
-
): Observable<T | null> {
|
|
37
|
-
const whereAreYouEvent = `${componentTag}-where-are-you`
|
|
38
|
-
const hereIAmEvent = `${componentTag}-here-i-am`
|
|
39
|
-
|
|
40
|
-
return new Observable(subscriber => {
|
|
41
|
-
// Listen for response first (you were right!)
|
|
42
|
-
const subscription = fromEvent<CustomEvent>(window, hereIAmEvent)
|
|
43
|
-
.pipe(
|
|
44
|
-
takeUntil(timer(timeout)),
|
|
45
|
-
map(e => e.detail.component as T),
|
|
46
|
-
defaultIfEmpty(null),
|
|
47
|
-
)
|
|
48
|
-
.subscribe(component => {
|
|
49
|
-
subscriber.next(component)
|
|
50
|
-
subscriber.complete()
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
// Then dispatch discovery request
|
|
54
|
-
window.dispatchEvent(
|
|
55
|
-
new CustomEvent(whereAreYouEvent, {
|
|
56
|
-
bubbles: true,
|
|
57
|
-
composed: true,
|
|
58
|
-
}),
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
// Return cleanup function
|
|
62
|
-
return () => subscription.unsubscribe()
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Discover any of multiple components using race.
|
|
68
|
-
* Returns the first component that responds.
|
|
69
|
-
*
|
|
70
|
-
* @param componentTags - Array of component tag names to discover
|
|
71
|
-
* @returns Observable that emits the first discovered component or null if none found
|
|
72
|
-
*/
|
|
73
|
-
export function discoverAnyComponent<T extends HTMLElement>(...componentTags: string[]): Observable<T | null> {
|
|
74
|
-
if (componentTags.length === 0) {
|
|
75
|
-
return new Observable(subscriber => {
|
|
76
|
-
subscriber.next(null)
|
|
77
|
-
subscriber.complete()
|
|
78
|
-
})
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return race(...componentTags.map(tag => discoverComponent<T>(tag)))
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Universal element discovery - finds ANY element by CSS selector across shadow DOM boundaries.
|
|
86
|
-
* Uses event-based discovery pattern - no DOM traversal needed.
|
|
87
|
-
*
|
|
88
|
-
* How it works:
|
|
89
|
-
* 1. Broadcasts a discovery request event on window
|
|
90
|
-
* 2. All $LitElement components receive this event and check their shadow DOM
|
|
91
|
-
* 3. If a match is found, they respond with the element
|
|
92
|
-
*
|
|
93
|
-
* @param selector - CSS selector (e.g., '#my-id', '.my-class', '[data-attr]')
|
|
94
|
-
* @param timeout - How long to wait for a response in milliseconds (default: 150)
|
|
95
|
-
* @returns Observable that emits the discovered element or null if not found
|
|
96
|
-
*
|
|
97
|
-
* @example
|
|
98
|
-
* ```typescript
|
|
99
|
-
* // Find element by ID across shadow boundaries
|
|
100
|
-
* discoverElement('#app-card').subscribe(el => {
|
|
101
|
-
* if (el) console.log('Found:', el)
|
|
102
|
-
* })
|
|
103
|
-
*
|
|
104
|
-
* // Find element by class
|
|
105
|
-
* discoverElement('.special-button').subscribe(el => {...})
|
|
106
|
-
* ```
|
|
107
|
-
*/
|
|
108
|
-
export function discoverElement<T extends HTMLElement>(
|
|
109
|
-
selector: string,
|
|
110
|
-
timeout = 150,
|
|
111
|
-
): Observable<T | null> {
|
|
112
|
-
const requestId = `discover-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
113
|
-
|
|
114
|
-
return new Observable(subscriber => {
|
|
115
|
-
// Listen for response first
|
|
116
|
-
const subscription = fromEvent<CustomEvent<DiscoverResponse>>(window, DISCOVER_RESPONSE_EVENT)
|
|
117
|
-
.pipe(
|
|
118
|
-
takeUntil(timer(timeout)),
|
|
119
|
-
map(e => e.detail),
|
|
120
|
-
// Filter for our specific request
|
|
121
|
-
map(detail => (detail.requestId === requestId ? (detail.element as T) : null)),
|
|
122
|
-
// Only take the first non-null response
|
|
123
|
-
take(1),
|
|
124
|
-
defaultIfEmpty(null),
|
|
125
|
-
)
|
|
126
|
-
.subscribe(element => {
|
|
127
|
-
subscriber.next(element)
|
|
128
|
-
subscriber.complete()
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
// Broadcast discovery request
|
|
132
|
-
window.dispatchEvent(
|
|
133
|
-
new CustomEvent<DiscoverRequest>(DISCOVER_EVENT, {
|
|
134
|
-
detail: { selector, requestId },
|
|
135
|
-
bubbles: true,
|
|
136
|
-
composed: true,
|
|
137
|
-
}),
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
return () => subscription.unsubscribe()
|
|
141
|
-
})
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Discover multiple elements matching a selector.
|
|
146
|
-
* Collects all responses within the timeout period.
|
|
147
|
-
*
|
|
148
|
-
* @param selector - CSS selector
|
|
149
|
-
* @param timeout - How long to collect responses (default: 150ms)
|
|
150
|
-
* @returns Observable that emits array of discovered elements
|
|
151
|
-
*/
|
|
152
|
-
export function discoverAllElements<T extends HTMLElement>(
|
|
153
|
-
selector: string,
|
|
154
|
-
timeout = 150,
|
|
155
|
-
): Observable<T[]> {
|
|
156
|
-
const requestId = `discover-all-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
157
|
-
const elements: T[] = []
|
|
158
|
-
|
|
159
|
-
return new Observable(subscriber => {
|
|
160
|
-
// Collect all responses
|
|
161
|
-
const subscription = fromEvent<CustomEvent<DiscoverResponse>>(window, DISCOVER_RESPONSE_EVENT)
|
|
162
|
-
.pipe(takeUntil(timer(timeout)))
|
|
163
|
-
.subscribe({
|
|
164
|
-
next: e => {
|
|
165
|
-
if (e.detail.requestId === requestId) {
|
|
166
|
-
elements.push(e.detail.element as T)
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
complete: () => {
|
|
170
|
-
subscriber.next(elements)
|
|
171
|
-
subscriber.complete()
|
|
172
|
-
},
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
// Broadcast discovery request
|
|
176
|
-
window.dispatchEvent(
|
|
177
|
-
new CustomEvent<DiscoverRequest>(DISCOVER_EVENT, {
|
|
178
|
-
detail: { selector, requestId },
|
|
179
|
-
bubbles: true,
|
|
180
|
-
composed: true,
|
|
181
|
-
}),
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
return () => subscription.unsubscribe()
|
|
185
|
-
})
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Smart discovery - automatically detects if input is a CSS selector or component tag.
|
|
190
|
-
*
|
|
191
|
-
* @param query - CSS selector (starts with #, ., [) OR component tag name
|
|
192
|
-
* @param timeout - How long to wait (default: 150ms)
|
|
193
|
-
* @returns Observable that emits the discovered element or null
|
|
194
|
-
*
|
|
195
|
-
* @example
|
|
196
|
-
* ```typescript
|
|
197
|
-
* // CSS selector - uses discoverElement
|
|
198
|
-
* discover('#my-element').subscribe(...)
|
|
199
|
-
*
|
|
200
|
-
* // Component tag - uses discoverComponent
|
|
201
|
-
* discover('schmancy-fancy').subscribe(...)
|
|
202
|
-
* ```
|
|
203
|
-
*/
|
|
204
|
-
export function discover<T extends HTMLElement>(
|
|
205
|
-
query: string,
|
|
206
|
-
timeout = 150,
|
|
207
|
-
): Observable<T | null> {
|
|
208
|
-
// Check if it's a CSS selector (starts with #, ., or [)
|
|
209
|
-
const isCssSelector = /^[#.\[]/.test(query)
|
|
210
|
-
|
|
211
|
-
if (isCssSelector) {
|
|
212
|
-
return discoverElement<T>(query, timeout)
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Otherwise treat as component tag name
|
|
216
|
-
return discoverComponent<T>(query, timeout)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Export event names for use in baseElement
|
|
220
|
-
export { DISCOVER_EVENT, DISCOVER_RESPONSE_EVENT }
|
|
221
|
-
export type { DiscoverRequest, DiscoverResponse }
|
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
import { CSSResult, LitElement, PropertyValueMap } from 'lit'
|
|
2
|
-
import { property } from 'lit/decorators.js'
|
|
3
|
-
import { IBaseMixin } from './baseElement'
|
|
4
|
-
import { Constructor } from './constructor'
|
|
5
|
-
import { ITailwindElementMixin, TailwindElement } from './tailwind.mixin'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Cross-realm brand used by `<schmancy-form>` to discover form fields by
|
|
9
|
-
* inheritance rather than tag-name allowlists. `Symbol.for` puts the symbol in
|
|
10
|
-
* the global registry so detection works across module realms/bundles.
|
|
11
|
-
*/
|
|
12
|
-
export const SCHMANCY_FORM_FIELD = Symbol.for('schmancy.form-field')
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Interface defining the properties and methods that the FormFieldMixin adds.
|
|
16
|
-
*/
|
|
17
|
-
export interface IFormFieldMixin extends Element {
|
|
18
|
-
name: string
|
|
19
|
-
value: string | string[] | boolean | number | undefined
|
|
20
|
-
label: string
|
|
21
|
-
required: boolean
|
|
22
|
-
disabled: boolean
|
|
23
|
-
readonly: boolean
|
|
24
|
-
error: boolean
|
|
25
|
-
validationMessage: string
|
|
26
|
-
hint?: string
|
|
27
|
-
id: string
|
|
28
|
-
|
|
29
|
-
form: HTMLFormElement | null
|
|
30
|
-
|
|
31
|
-
checkValidity(): boolean
|
|
32
|
-
reportValidity(): boolean
|
|
33
|
-
setCustomValidity(message: string): void
|
|
34
|
-
|
|
35
|
-
toFormEntries(): Array<[string, FormDataEntryValue]>
|
|
36
|
-
resetForm(): void
|
|
37
|
-
|
|
38
|
-
emitChange(detail: any): void
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Predicate used by `<schmancy-form>` to detect mixin descendants. */
|
|
42
|
-
export function isSchmancyFormField(el: unknown): el is IFormFieldMixin {
|
|
43
|
-
return !!el && typeof el === 'object' && (el as any).constructor?.[SCHMANCY_FORM_FIELD] === true
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* A mixin that adds form field capabilities to a LitElement class.
|
|
48
|
-
* Components that extend this mixin are automatically discovered and
|
|
49
|
-
* collected by `<schmancy-form>` — no tag-name registration needed.
|
|
50
|
-
*
|
|
51
|
-
* Subclasses may override `toFormEntries()` to contribute multiple
|
|
52
|
-
* name/value pairs to FormData (e.g. date-range, tag-input).
|
|
53
|
-
*
|
|
54
|
-
* @example
|
|
55
|
-
* ```ts
|
|
56
|
-
* class MyInput extends FormFieldMixin(TailwindElement(css`...`)) {
|
|
57
|
-
* // Your component code here
|
|
58
|
-
* }
|
|
59
|
-
* ```
|
|
60
|
-
*/
|
|
61
|
-
export function FormFieldMixin<T extends Constructor<LitElement>>(superClass: T) {
|
|
62
|
-
class FormFieldMixinClass extends superClass {
|
|
63
|
-
static formAssociated = true
|
|
64
|
-
|
|
65
|
-
/** Brand for cross-realm detection by `<schmancy-form>`. */
|
|
66
|
-
static readonly [SCHMANCY_FORM_FIELD] = true
|
|
67
|
-
|
|
68
|
-
// Element internals for form association
|
|
69
|
-
internals: ElementInternals | undefined
|
|
70
|
-
|
|
71
|
-
/** Value snapshot captured at first render, used by `resetForm()`. */
|
|
72
|
-
protected _defaultValue: string | string[] | boolean | number | undefined = undefined
|
|
73
|
-
|
|
74
|
-
@property({ type: String })
|
|
75
|
-
name: string = ''
|
|
76
|
-
|
|
77
|
-
@property({ reflect: true })
|
|
78
|
-
value: string | string[] | boolean | number | undefined = ''
|
|
79
|
-
|
|
80
|
-
@property({ type: String })
|
|
81
|
-
label: string = ''
|
|
82
|
-
|
|
83
|
-
@property({ type: Boolean, reflect: true })
|
|
84
|
-
required: boolean = false
|
|
85
|
-
|
|
86
|
-
@property({ type: Boolean, reflect: true })
|
|
87
|
-
disabled: boolean = false
|
|
88
|
-
|
|
89
|
-
@property({ type: Boolean, reflect: true })
|
|
90
|
-
readonly: boolean = false
|
|
91
|
-
|
|
92
|
-
@property({ type: Boolean, reflect: true })
|
|
93
|
-
error: boolean = false
|
|
94
|
-
|
|
95
|
-
@property({ type: String })
|
|
96
|
-
validationMessage: string = ''
|
|
97
|
-
|
|
98
|
-
@property({ type: String })
|
|
99
|
-
hint?: string
|
|
100
|
-
|
|
101
|
-
@property({ reflect: true })
|
|
102
|
-
override id: string = `schmancy-field-${Date.now()}-${Math.floor(Math.random() * 1000)}`
|
|
103
|
-
|
|
104
|
-
constructor(...args: any[]) {
|
|
105
|
-
super(...args)
|
|
106
|
-
try {
|
|
107
|
-
this.internals = this.attachInternals()
|
|
108
|
-
} catch {
|
|
109
|
-
this.internals = undefined
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** The form this element is associated with (native FACE behavior). */
|
|
114
|
-
get form(): HTMLFormElement | null {
|
|
115
|
-
return this.internals?.form ?? null
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
protected firstUpdated(changedProps: PropertyValueMap<any>): void {
|
|
119
|
-
super.firstUpdated?.(changedProps)
|
|
120
|
-
if (this._defaultValue === undefined) this._defaultValue = this.value
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
protected willUpdate(changedProps: PropertyValueMap<any>): void {
|
|
124
|
-
super.willUpdate(changedProps)
|
|
125
|
-
|
|
126
|
-
if (changedProps.has('value')) {
|
|
127
|
-
this.internals?.setFormValue(this.value as string | File | FormData | null)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (changedProps.has('error') || changedProps.has('validationMessage')) {
|
|
131
|
-
if (this.error && this.validationMessage) {
|
|
132
|
-
this.internals?.setValidity({ customError: true }, this.validationMessage)
|
|
133
|
-
} else {
|
|
134
|
-
this.internals?.setValidity({})
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Broadcast standard field states for consumer CSS: :state(invalid),
|
|
139
|
-
// :state(required), :state(disabled), :state(readonly).
|
|
140
|
-
if (changedProps.has('error')) {
|
|
141
|
-
if (this.error) this.internals?.states.add('invalid')
|
|
142
|
-
else this.internals?.states.delete('invalid')
|
|
143
|
-
}
|
|
144
|
-
if (changedProps.has('required')) {
|
|
145
|
-
if (this.required) this.internals?.states.add('required')
|
|
146
|
-
else this.internals?.states.delete('required')
|
|
147
|
-
}
|
|
148
|
-
if (changedProps.has('disabled')) {
|
|
149
|
-
if (this.disabled) this.internals?.states.add('disabled')
|
|
150
|
-
else this.internals?.states.delete('disabled')
|
|
151
|
-
}
|
|
152
|
-
if (changedProps.has('readonly')) {
|
|
153
|
-
if (this.readonly) this.internals?.states.add('readonly')
|
|
154
|
-
else this.internals?.states.delete('readonly')
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Native FACE lifecycle — called by the browser when the owning form
|
|
160
|
-
* is reset. Delegates to `resetForm()` so subclasses have one
|
|
161
|
-
* override point for both programmatic and user-initiated resets.
|
|
162
|
-
*/
|
|
163
|
-
formResetCallback(): void {
|
|
164
|
-
this.resetForm()
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Native FACE lifecycle — called when the form's disabled state changes. */
|
|
168
|
-
formDisabledCallback(disabled: boolean): void {
|
|
169
|
-
this.disabled = disabled
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Native FACE lifecycle — restore value after bfcache / form autofill.
|
|
174
|
-
*/
|
|
175
|
-
formStateRestoreCallback(state: string | File | FormData | null): void {
|
|
176
|
-
if (state == null) return
|
|
177
|
-
this.value = state as any
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Override to customize reset behavior; default restores `_defaultValue`. */
|
|
181
|
-
resetForm(): void {
|
|
182
|
-
this.value = this._defaultValue ?? ''
|
|
183
|
-
this.error = false
|
|
184
|
-
this.validationMessage = ''
|
|
185
|
-
this.internals?.setValidity({})
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Contribute entries to a parent FormData. Default: a single
|
|
190
|
-
* `[name, value]` pair when `name` is set and value is meaningful.
|
|
191
|
-
* Override for multi-entry controls (e.g. date range).
|
|
192
|
-
*/
|
|
193
|
-
toFormEntries(): Array<[string, FormDataEntryValue]> {
|
|
194
|
-
if (!this.name || this.disabled) return []
|
|
195
|
-
const v = this.value
|
|
196
|
-
if (v === undefined || v === null || v === '') return []
|
|
197
|
-
if (Array.isArray(v)) return v.map(item => [this.name, String(item)] as [string, FormDataEntryValue])
|
|
198
|
-
if (typeof v === 'boolean') return v ? [[this.name, 'on']] : []
|
|
199
|
-
return [[this.name, String(v)]]
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
checkValidity(): boolean {
|
|
203
|
-
if (this.disabled) return true
|
|
204
|
-
if (this.required && (this.value === '' || this.value === undefined || this.value === null)) {
|
|
205
|
-
this.error = true
|
|
206
|
-
this.validationMessage = 'This field is required'
|
|
207
|
-
return false
|
|
208
|
-
}
|
|
209
|
-
return this.internals?.checkValidity() ?? true
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
reportValidity(): boolean {
|
|
213
|
-
const isValid = this.checkValidity()
|
|
214
|
-
if (!isValid) this.internals?.reportValidity()
|
|
215
|
-
return isValid
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
setCustomValidity(message: string): void {
|
|
219
|
-
this.validationMessage = message
|
|
220
|
-
this.error = message !== ''
|
|
221
|
-
if (message) {
|
|
222
|
-
this.internals?.setValidity({ customError: true }, message)
|
|
223
|
-
} else {
|
|
224
|
-
this.internals?.setValidity({})
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
emitChange(detail: any): void {
|
|
229
|
-
if ('dispatchScopedEvent' in this && typeof this.dispatchScopedEvent === 'function') {
|
|
230
|
-
this.dispatchScopedEvent('change', detail, { bubbles: true })
|
|
231
|
-
} else {
|
|
232
|
-
this.dispatchEvent(
|
|
233
|
-
new CustomEvent('change', {
|
|
234
|
-
detail,
|
|
235
|
-
bubbles: true,
|
|
236
|
-
composed: true,
|
|
237
|
-
}),
|
|
238
|
-
)
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return FormFieldMixinClass as Constructor<IFormFieldMixin> & T
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* A convenience function that composes FormFieldMixin with TailwindElement
|
|
248
|
-
* to create a base class for Schmancy form components.
|
|
249
|
-
*/
|
|
250
|
-
export function SchmancyFormField<T extends CSSResult>(componentStyle?: T) {
|
|
251
|
-
return FormFieldMixin(TailwindElement(componentStyle)) as Constructor<IFormFieldMixin> &
|
|
252
|
-
Constructor<ITailwindElementMixin> &
|
|
253
|
-
Constructor<LitElement> &
|
|
254
|
-
Constructor<IBaseMixin>
|
|
255
|
-
}
|