stalefish 8.1.2 → 8.1.13
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/components/checkbox.mjs +33 -6
- package/components/datePicker.mjs +56 -16
- package/components/selectbox.mjs +6 -5
- package/components/textarea.mjs +1 -1
- package/components/textfield.mjs +240 -16
- package/components/timePicker.mjs +52 -7
- package/components/uploader.mjs +70 -3
- package/example/app.mjs +15 -11
- package/example/index.html +1 -0
- package/package.json +2 -2
package/components/checkbox.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { html, css, formField, fieldIsTouched } from 'halfcab'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const styles = css`
|
|
4
4
|
.checkbox {
|
|
5
5
|
margin: 0 10px 0 20px !important;
|
|
6
6
|
position: absolute;
|
|
@@ -8,6 +8,11 @@ let styles = css`
|
|
|
8
8
|
top: 16.5px;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/* Do not show native blue focus ring on the actual checkbox; we use the
|
|
12
|
+
outer label's darker border (.activeFocus) as the visual focus cue. */
|
|
13
|
+
.checkbox:focus { outline: none; box-shadow: none; }
|
|
14
|
+
.checkbox:focus-visible { outline: none; box-shadow: none; }
|
|
15
|
+
|
|
11
16
|
.label {
|
|
12
17
|
position: relative;
|
|
13
18
|
display: inline-flex;
|
|
@@ -15,7 +20,7 @@ let styles = css`
|
|
|
15
20
|
border-radius: 0;
|
|
16
21
|
font-size: 18px;
|
|
17
22
|
font-weight: normal;
|
|
18
|
-
color: #
|
|
23
|
+
color: #666;
|
|
19
24
|
margin: 5px 5px 5px 5px;
|
|
20
25
|
border: solid 5px #c9c9c9;
|
|
21
26
|
user-select: none;
|
|
@@ -24,16 +29,38 @@ let styles = css`
|
|
|
24
29
|
width: calc(100% - 10px);
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
/* Programmatic visual focus/active indicator to match other fields */
|
|
33
|
+
.activeFocus {
|
|
34
|
+
border: solid 5px #969696;
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
.checkbox.touched:invalid:not(:focus) {
|
|
28
38
|
outline: red solid 2px;
|
|
29
39
|
}
|
|
30
40
|
`
|
|
31
41
|
|
|
32
42
|
export default ({ wrapperStyle, holdingPen, label, property, required, indeterminate, disabled, darkBackground, onchange }) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
const addActive = (e) => {
|
|
44
|
+
if (!e) return
|
|
45
|
+
const wrapper = e.target && typeof e.target.closest === 'function' && e.target.closest('label')
|
|
46
|
+
if (!wrapper) return
|
|
47
|
+
const box = wrapper.querySelector('.' + styles.label)
|
|
48
|
+
if (box && box.classList) box.classList.add(styles.activeFocus)
|
|
49
|
+
}
|
|
50
|
+
const removeActive = (e) => {
|
|
51
|
+
if (!e) return
|
|
52
|
+
const wrapper = e.target && typeof e.target.closest === 'function' && e.target.closest('label')
|
|
53
|
+
if (!wrapper) return
|
|
54
|
+
const box = wrapper.querySelector('.' + styles.label)
|
|
55
|
+
if (box && box.classList) box.classList.remove(styles.activeFocus)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const checkboxEl = html`<input data-gramm="false" ?disabled=${disabled} style="${disabled ? 'cursor: not-allowed;' : ''}" class="${styles.checkbox} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" value="${holdingPen[property] === true ? 'true' : null}" ?checked=${holdingPen[property] === true}
|
|
59
|
+
onchange=${e => { formField(holdingPen, property)(e); onchange && onchange(e); addActive(e) }}
|
|
60
|
+
onfocus=${e => { addActive(e) }}
|
|
61
|
+
onblur=${e => { removeActive(e) }}
|
|
62
|
+
onclick=${e => { addActive(e) }}
|
|
63
|
+
type="checkbox" ?required=${required} />`
|
|
37
64
|
|
|
38
65
|
checkboxEl.indeterminate = indeterminate || false
|
|
39
66
|
|
|
@@ -24,7 +24,7 @@ const styles = css`
|
|
|
24
24
|
position: relative;
|
|
25
25
|
height: 55px;
|
|
26
26
|
background-color: #FFF;
|
|
27
|
-
color: #
|
|
27
|
+
color: #666;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
.noType {
|
|
@@ -34,6 +34,9 @@ const styles = css`
|
|
|
34
34
|
|
|
35
35
|
.textfield::placeholder { color: #999; }
|
|
36
36
|
|
|
37
|
+
/* Programmatic visual focus when interacting via popup/nav */
|
|
38
|
+
.activeFocus { border: solid 5px #969696; }
|
|
39
|
+
|
|
37
40
|
.label {
|
|
38
41
|
transition: opacity 0.75s;
|
|
39
42
|
border-top-right-radius: 5px;
|
|
@@ -204,25 +207,25 @@ function buildCalendarUI ({ state, today, onPickDay, onPrevMonth, onNextMonth, o
|
|
|
204
207
|
|
|
205
208
|
return html`
|
|
206
209
|
<div class="${styles.calHeader}">
|
|
207
|
-
<button type="button" class="${styles.navBtn}" onclick=${onPrevMonth} aria-label="Previous month">
|
|
208
|
-
<span class="${styles.arrowIcon}"
|
|
210
|
+
<button type="button" class="${styles.navBtn}" onclick=${onPrevMonth} aria-label="Previous month" style="transform: rotate(90deg);" tabindex="-1" onmousedown=${(e) => e.preventDefault()}>
|
|
211
|
+
<span class="${styles.arrowIcon}">${solidDown({ colour: '#ccc', width: '13', height: '13' })}</span>
|
|
209
212
|
</button>
|
|
210
213
|
<div class="${styles.monthLabel}">
|
|
211
214
|
<span>${monthNames[m]}</span>
|
|
212
215
|
<span class="${styles.yearWrap}" aria-label="Year controls">
|
|
213
216
|
<span class="${styles.yearNum}">${y}</span>
|
|
214
217
|
<span class="${styles.yearControls}">
|
|
215
|
-
<button type="button" class="${styles.yearBtn}" onclick=${onNextYear} aria-label="Next year">
|
|
216
|
-
<span class="${styles.yearArrowIcon}"
|
|
218
|
+
<button type="button" class="${styles.yearBtn}" onclick=${onNextYear} aria-label="Next year" style="transform: rotate(180deg);" tabindex="-1" onmousedown=${(e) => e.preventDefault()}>
|
|
219
|
+
<span class="${styles.yearArrowIcon}">${solidDown({ colour: '#ccc', width: '12', height: '12' })}</span>
|
|
217
220
|
</button>
|
|
218
|
-
<button type="button" class="${styles.yearBtn}" onclick=${onPrevYear} aria-label="Previous year">
|
|
219
|
-
<span class="${styles.yearArrowIcon}"
|
|
221
|
+
<button type="button" class="${styles.yearBtn}" onclick=${onPrevYear} aria-label="Previous year" style="transform: rotate(0deg);" tabindex="-1" onmousedown=${(e) => e.preventDefault()}>
|
|
222
|
+
<span class="${styles.yearArrowIcon}">${solidDown({ colour: '#ccc', width: '12', height: '12' })}</span>
|
|
220
223
|
</button>
|
|
221
224
|
</span>
|
|
222
225
|
</span>
|
|
223
226
|
</div>
|
|
224
|
-
<button type="button" class="${styles.navBtn}" onclick=${onNextMonth} aria-label="Next month">
|
|
225
|
-
<span class="${styles.arrowIcon}"
|
|
227
|
+
<button type="button" class="${styles.navBtn}" onclick=${onNextMonth} aria-label="Next month" style="transform: rotate(270deg);" tabindex="-1" onmousedown=${(e) => e.preventDefault()}>
|
|
228
|
+
<span class="${styles.arrowIcon}">${solidDown({ colour: '#ccc', width: '13', height: '13' })}</span>
|
|
226
229
|
</button>
|
|
227
230
|
</div>
|
|
228
231
|
<div class="${styles.weekHead}">${weekNames.map(w => html`<div style="text-align:center;">${w}</div>`)}</div>
|
|
@@ -233,7 +236,7 @@ function buildCalendarUI ({ state, today, onPickDay, onPrevMonth, onNextMonth, o
|
|
|
233
236
|
const selStyle = isSelected(d) ? 'background:#48aaf3; color:#fff;' : 'color:#999;'
|
|
234
237
|
const bgStyle = isSelected(d) ? '' : 'background:#fff;'
|
|
235
238
|
const todayStyle = isToday(d) ? 'box-shadow: inset 0 0 0 2px #c9c9c9;' : ''
|
|
236
|
-
return html`<button type="button" style="padding:6px; ${selStyle} ${bgStyle} ${todayStyle} border:1px solid #c9c9c9; cursor:pointer;" onclick=${() => onPickDay(d)}>${d}</button>`
|
|
239
|
+
return html`<button type="button" style="padding:6px; ${selStyle} ${bgStyle} ${todayStyle} border:1px solid #c9c9c9; cursor:pointer;" onclick=${() => onPickDay(d)} tabindex="-1" onmousedown=${(e) => e.preventDefault()}>${d}</button>`
|
|
237
240
|
})}
|
|
238
241
|
</div>
|
|
239
242
|
`
|
|
@@ -268,11 +271,25 @@ export default function datePicker ({
|
|
|
268
271
|
state.tmpDate = parseYMD(currentStr) || new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
269
272
|
}
|
|
270
273
|
|
|
274
|
+
// Emphasize focus styling on the input and keep it until blur/close
|
|
275
|
+
const pulseActive = () => {
|
|
276
|
+
if (typeof window === 'undefined') return
|
|
277
|
+
setTimeout(() => {
|
|
278
|
+
const el = document.getElementById(`${wrapperId}-input`)
|
|
279
|
+
if (!el || el.disabled) return
|
|
280
|
+
try { el.focus && el.focus({ preventScroll: true }) } catch (e) {}
|
|
281
|
+
el.classList && el.classList.add(styles.activeFocus)
|
|
282
|
+
// Do not auto-remove; rely on blur/close logic below
|
|
283
|
+
}, 0)
|
|
284
|
+
}
|
|
285
|
+
|
|
271
286
|
const commit = () => {
|
|
272
287
|
const valueStr = state.tmpDate ? toYMD(state.tmpDate) : ''
|
|
273
288
|
commitValue({ holdingPen, property, onchange, valueStr })
|
|
274
289
|
state.open = false
|
|
275
290
|
rerender()
|
|
291
|
+
// Keep focus style only if input retains focus; otherwise it will be cleared on blur
|
|
292
|
+
pulseActive()
|
|
276
293
|
}
|
|
277
294
|
|
|
278
295
|
const clearValue = (e) => {
|
|
@@ -280,6 +297,7 @@ export default function datePicker ({
|
|
|
280
297
|
commitValue({ holdingPen, property, onchange, valueStr: '' })
|
|
281
298
|
state.open = false
|
|
282
299
|
rerender()
|
|
300
|
+
pulseActive()
|
|
283
301
|
return false
|
|
284
302
|
}
|
|
285
303
|
|
|
@@ -292,16 +310,26 @@ export default function datePicker ({
|
|
|
292
310
|
if (state.open) return
|
|
293
311
|
state.open = true
|
|
294
312
|
rerender()
|
|
313
|
+
pulseActive()
|
|
295
314
|
if (typeof window !== 'undefined' && state.open) {
|
|
315
|
+
// Clean up any previous listener in case of defensive re-open
|
|
316
|
+
if (state._closeOnOutside) {
|
|
317
|
+
try { document.removeEventListener('mousedown', state._closeOnOutside, true) } catch {}
|
|
318
|
+
}
|
|
296
319
|
const closeOnOutside = (ev) => {
|
|
297
320
|
const wrapperEl = document.getElementById(wrapperId)
|
|
298
321
|
if (!wrapperEl) return
|
|
299
322
|
if (!wrapperEl.contains(ev.target)) {
|
|
300
323
|
state.open = false
|
|
301
324
|
rerender()
|
|
325
|
+
// Remove active styling when popup closes due to outside click
|
|
326
|
+
const el = document.getElementById(`${wrapperId}-input`)
|
|
327
|
+
if (el && el.classList) el.classList.remove(styles.activeFocus)
|
|
302
328
|
document.removeEventListener('mousedown', closeOnOutside, true)
|
|
329
|
+
state._closeOnOutside = null
|
|
303
330
|
}
|
|
304
331
|
}
|
|
332
|
+
state._closeOnOutside = closeOnOutside
|
|
305
333
|
setTimeout(() => document.addEventListener('mousedown', closeOnOutside, true), 0)
|
|
306
334
|
}
|
|
307
335
|
}
|
|
@@ -312,10 +340,10 @@ export default function datePicker ({
|
|
|
312
340
|
state,
|
|
313
341
|
today: new Date(),
|
|
314
342
|
onPickDay: (d) => { state.tmpDate = new Date(state.viewYear, state.viewMonth, d); commit() },
|
|
315
|
-
onPrevMonth: () => { if (state.viewMonth === 0) { state.viewMonth = 11; state.viewYear -= 1 } else { state.viewMonth -= 1 }
|
|
316
|
-
onNextMonth: () => { if (state.viewMonth === 11) { state.viewMonth = 0; state.viewYear += 1 } else { state.viewMonth += 1 }
|
|
317
|
-
onPrevYear: () => { state.viewYear -= 1; rerender() },
|
|
318
|
-
onNextYear: () => { state.viewYear += 1; rerender() }
|
|
343
|
+
onPrevMonth: () => { if (state.viewMonth === 0) { state.viewMonth = 11; state.viewYear -= 1 } else { state.viewMonth -= 1 } rerender(); pulseActive() },
|
|
344
|
+
onNextMonth: () => { if (state.viewMonth === 11) { state.viewMonth = 0; state.viewYear += 1 } else { state.viewMonth += 1 } rerender(); pulseActive() },
|
|
345
|
+
onPrevYear: () => { state.viewYear -= 1; rerender(); pulseActive() },
|
|
346
|
+
onNextYear: () => { state.viewYear += 1; rerender(); pulseActive() }
|
|
319
347
|
})}
|
|
320
348
|
</div>`
|
|
321
349
|
: ''
|
|
@@ -336,9 +364,21 @@ export default function datePicker ({
|
|
|
336
364
|
${label ? html`<span class="${styles.label}" style="opacity: ${(holdingPen && (holdingPen[property] === 0 || holdingPen[property])) ? 1 : 0}; font-size: 16px; font-weight: normal; color: #999; margin-left: 5px; padding: 9px; background-color: rgba(255,255,255,0.8); position: absolute; top: -36px;">${label}${required ? ' *' : ''}</span>` : ''}
|
|
337
365
|
${!disableClear ? html`<div data-clear class="${styles.clear}" onclick=${clearValue}>clear</div>` : ''}
|
|
338
366
|
<div class="${styles.icon}">${calendarIcon({ colour: '#ccc', width: 20, height: 20 })}</div>
|
|
339
|
-
<input data-gramm="false" ?disabled=${disabled} style="${disabled ? 'cursor: not-allowed; opacity: 0.3;' : 'cursor: pointer;'}" class="${styles.textfield} ${styles.noType} ${styles.withRightIcon} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" ?required=${required}
|
|
367
|
+
<input id="${wrapperId}-input" data-gramm="false" ?disabled=${disabled} style="${disabled ? 'cursor: not-allowed; opacity: 0.3;' : 'cursor: pointer;'}" class="${styles.textfield} ${styles.noType} ${styles.withRightIcon} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" ?required=${required}
|
|
340
368
|
onclick=${open}
|
|
341
|
-
onfocus=${open}
|
|
369
|
+
onfocus=${(e) => { open(e); const el = e && e.target; if (el && el.classList) el.classList.add(styles.activeFocus) }}
|
|
370
|
+
onblur=${(e) => {
|
|
371
|
+
// Close the popup when tabbing/clicking away to avoid multiple open popups
|
|
372
|
+
state.open = false
|
|
373
|
+
rerender()
|
|
374
|
+
const el = e && e.target
|
|
375
|
+
if (el && el.classList) el.classList.remove(styles.activeFocus)
|
|
376
|
+
// Also remove any pending outside-click listener
|
|
377
|
+
if (typeof window !== 'undefined' && state._closeOnOutside) {
|
|
378
|
+
try { document.removeEventListener('mousedown', state._closeOnOutside, true) } catch {}
|
|
379
|
+
state._closeOnOutside = null
|
|
380
|
+
}
|
|
381
|
+
}}
|
|
342
382
|
readonly
|
|
343
383
|
placeholder="${(placeholder || 'Date') + (required ? ' *' : '')}"
|
|
344
384
|
type="text" ${pattern ? { pattern } : ''}
|
package/components/selectbox.mjs
CHANGED
|
@@ -36,7 +36,7 @@ const styles = css`
|
|
|
36
36
|
left: 5px;
|
|
37
37
|
font-size: 16px;
|
|
38
38
|
font-weight: normal;
|
|
39
|
-
color: #
|
|
39
|
+
color: #666;
|
|
40
40
|
margin-left: 5px;
|
|
41
41
|
padding: 9px;
|
|
42
42
|
background-color: rgba(255,255,255,0.8);
|
|
@@ -70,18 +70,19 @@ export default ({ wrapperStyle = null, holdingPen, label, property, options, req
|
|
|
70
70
|
return option === holdingPen[property]
|
|
71
71
|
}
|
|
72
72
|
})
|
|
73
|
-
// text color is always #999 per latest design; placeholder state no longer affects color
|
|
74
73
|
|
|
75
74
|
const wrapperClassName = wrapperStyle
|
|
76
75
|
? (typeof wrapperStyle === 'string' ? wrapperStyle : (wrapperStyle.toString ? wrapperStyle.toString() : ''))
|
|
77
76
|
: ''
|
|
78
77
|
|
|
78
|
+
const isPlaceholder = !currentOption || (typeof currentOption === 'object' && currentOption.value === '') || currentOption === ''
|
|
79
|
+
|
|
79
80
|
return html`
|
|
80
81
|
<label style="text-align: left; position: relative; display: inline-block; width: 100%;" class="${wrapperClassName}">
|
|
81
82
|
<div class="${styles.down}">${solidDown({ colour: '#ccc' })}</div>
|
|
82
83
|
<span class="${styles.label}">${label}${required ? ' *' : ''}</span>
|
|
83
|
-
<select ?disabled=${disabled} style="${disabled ? 'cursor: not-allowed; opacity: 0.3;' : ''}background-color: ${typeof currentOption === 'object' && currentOption.colour ? `#${currentOption.colour}` : 'white'}; color: #999;" class="${styles.selectBox} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" oninput=${e => { formField(holdingPen, property)(e); oninput && oninput(e) }} onchange=${e => { formField(holdingPen, property)(e); onchange && onchange(e) }} onblur=${formField(holdingPen, property)}>
|
|
84
|
-
<option value="${required ? 'Select an option' : ''}" ?selected=${!holdingPen[property]} ?disabled=${required}
|
|
84
|
+
<select ?disabled=${disabled} style="${disabled ? 'cursor: not-allowed; opacity: 0.3;' : ''}background-color: ${typeof currentOption === 'object' && currentOption.colour ? `#${currentOption.colour}` : 'white'}; color: ${isPlaceholder ? '#999' : '#666'};" class="${styles.selectBox} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" oninput=${e => { formField(holdingPen, property)(e); oninput && oninput(e) }} onchange=${e => { formField(holdingPen, property)(e); onchange && onchange(e) }} onblur=${formField(holdingPen, property)}>
|
|
85
|
+
<option value="${required ? 'Select an option' : ''}" ?selected=${!holdingPen[property]} ?disabled=${required}>${required ? 'Select an option' : ''}</option>
|
|
85
86
|
${options.map(option => {
|
|
86
87
|
let optionValue
|
|
87
88
|
let optionName
|
|
@@ -91,7 +92,7 @@ export default ({ wrapperStyle = null, holdingPen, label, property, options, req
|
|
|
91
92
|
} else {
|
|
92
93
|
optionValue = option
|
|
93
94
|
}
|
|
94
|
-
return html`<option value="${optionValue}" ?selected=${holdingPen[property] == optionValue}>${optionName || optionValue}</option>` // eslint-disable-line
|
|
95
|
+
return html`<option style="color: ${optionValue === '' ? '#999' : '#666'};" value="${optionValue}" ?selected=${holdingPen[property] == optionValue}>${optionName || optionValue}</option>` // eslint-disable-line
|
|
95
96
|
})}
|
|
96
97
|
</select>
|
|
97
98
|
</label>
|
package/components/textarea.mjs
CHANGED
package/components/textfield.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { html, css, formField, fieldIsTouched } from 'halfcab'
|
|
1
|
+
import { html, css, formField, fieldIsTouched, rerender } from 'halfcab'
|
|
2
|
+
import solidDown from './icons/solidDown.mjs'
|
|
2
3
|
// language=CSS
|
|
3
4
|
const styles = css`
|
|
4
5
|
.textfield {
|
|
@@ -19,9 +20,14 @@ const styles = css`
|
|
|
19
20
|
appearance: none;
|
|
20
21
|
z-index: 20;
|
|
21
22
|
position: relative;
|
|
22
|
-
color: #
|
|
23
|
+
color: #666;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/* Provide room for custom right-side controls when present */
|
|
27
|
+
.withRightControls { padding-right: 44px; }
|
|
28
|
+
/* Legacy fixed padding when valueContext present (no longer used; kept for backward compat) */
|
|
29
|
+
.withRightControlsAndValueContext { padding-right: 114px; }
|
|
30
|
+
|
|
25
31
|
.label {
|
|
26
32
|
transition: opacity 0.75s;
|
|
27
33
|
border-top-right-radius: 5px;
|
|
@@ -32,6 +38,11 @@ const styles = css`
|
|
|
32
38
|
z-index: 10;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/* Color input slightly shifts internal box metrics; lift label by ~6px for perfect alignment */
|
|
42
|
+
.labelColorAdjustForColorType {
|
|
43
|
+
top: -61px;
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
.textfield.highlight {
|
|
36
47
|
border-color: #ff4081;
|
|
37
48
|
}
|
|
@@ -40,6 +51,11 @@ const styles = css`
|
|
|
40
51
|
border: solid 5px #969696;
|
|
41
52
|
}
|
|
42
53
|
|
|
54
|
+
/* Programmatic visual focus when clicking custom controls or related UI */
|
|
55
|
+
.activeFocus {
|
|
56
|
+
border: solid 5px #969696;
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
.textfield::placeholder {
|
|
44
60
|
color: #999;
|
|
45
61
|
}
|
|
@@ -48,30 +64,96 @@ const styles = css`
|
|
|
48
64
|
border-color: red;
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
/* Normalize native color input height to match other fields (55px)
|
|
68
|
+
Use the same padding as other textfields so overlay elements (label/valueContext)
|
|
69
|
+
align identically regardless of type. */
|
|
70
|
+
input[type="color"].textfield {
|
|
71
|
+
height: 55px; /* match date/time/text visual height */
|
|
72
|
+
padding: 10px; /* match standard textfield padding to avoid vertical shift */
|
|
73
|
+
}
|
|
74
|
+
/* WebKit-specific internals to remove extra inset spacing/border */
|
|
75
|
+
input[type="color"].textfield::-webkit-color-swatch-wrapper { padding: 0; }
|
|
76
|
+
input[type="color"].textfield::-webkit-color-swatch { border: none; }
|
|
77
|
+
|
|
51
78
|
.valueContext {
|
|
52
79
|
position: absolute;
|
|
53
80
|
color: #AAA;
|
|
54
81
|
font-size: 1.1em;
|
|
55
|
-
|
|
82
|
+
/* Enforce exact badge height and vertical centering */
|
|
83
|
+
height: 42px;
|
|
84
|
+
line-height: 42px;
|
|
56
85
|
font-weight: normal;
|
|
57
86
|
box-sizing: border-box;
|
|
58
87
|
top: -12px;
|
|
59
88
|
right: 7px;
|
|
60
89
|
background-color: #EEE;
|
|
61
|
-
padding
|
|
90
|
+
/* Horizontal padding only so height remains exactly 42px */
|
|
91
|
+
padding: 0 10px;
|
|
62
92
|
z-index: 30;
|
|
63
93
|
}
|
|
94
|
+
|
|
95
|
+
/* Custom up/down controls (aligned with datePicker/timePicker) */
|
|
96
|
+
.controls {
|
|
97
|
+
position: absolute;
|
|
98
|
+
right: 10px;
|
|
99
|
+
top: 50%;
|
|
100
|
+
transform: translateY(-50%);
|
|
101
|
+
display: flex;
|
|
102
|
+
flex-direction: column;
|
|
103
|
+
align-items: center;
|
|
104
|
+
gap: 2px;
|
|
105
|
+
z-index: 30;
|
|
106
|
+
width: 24px;
|
|
107
|
+
pointer-events: auto;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* Dynamic positioning is handled via JS when valueContext is present */
|
|
111
|
+
.controlsWithValueContext { right: 80px; }
|
|
112
|
+
|
|
113
|
+
.btn {
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
background: transparent;
|
|
116
|
+
border: none;
|
|
117
|
+
padding: 0;
|
|
118
|
+
line-height: 1;
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
justify-content: center;
|
|
122
|
+
width: 24px;
|
|
123
|
+
height: 16px;
|
|
124
|
+
}
|
|
125
|
+
.btn + .btn { margin-top: -3px; }
|
|
126
|
+
.btn:focus { outline: none; }
|
|
127
|
+
.btn:focus-visible { outline: 2px solid #c9c9c9; outline-offset: 2px; }
|
|
128
|
+
|
|
129
|
+
/* Match requested styling: light grey (#CCC) and ~40% smaller (12px -> 7px) */
|
|
130
|
+
.arrowIcon { display: block; width: 7px; height: 7px; color: #CCC; line-height: 0; transform-origin: 50% 50%; margin: 0 auto; }
|
|
131
|
+
.arrowIcon > svg { display: block; width: 7px; height: 7px; }
|
|
132
|
+
|
|
133
|
+
/* Hide native number spinners for consistency across browsers */
|
|
134
|
+
input[type="number"]::-webkit-outer-spin-button,
|
|
135
|
+
input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
136
|
+
input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
|
|
64
137
|
`
|
|
65
138
|
|
|
66
139
|
function change ({ e, holdingPen, property, label }) {
|
|
67
140
|
const ff = formField(holdingPen, property)(e)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
141
|
+
|
|
142
|
+
// When programmatically changing via custom arrows we may not have e.target
|
|
143
|
+
// Guard DOM lookups so we don't throw and skip label opacity tweaks in that case
|
|
144
|
+
const target = e && (e.target || e.currentTarget)
|
|
145
|
+
const canQuery = target && typeof target.closest === 'function'
|
|
146
|
+
if (canQuery) {
|
|
147
|
+
const closestLabel = target.closest('label')
|
|
148
|
+
if (closestLabel) {
|
|
149
|
+
const labelEl = label && label.selector ? closestLabel.querySelector(label.selector) : null
|
|
150
|
+
if (labelEl) {
|
|
151
|
+
if (holdingPen[property] === 0 || holdingPen[property]) {
|
|
152
|
+
labelEl.style.opacity = 1
|
|
153
|
+
} else {
|
|
154
|
+
labelEl.style.opacity = 0
|
|
155
|
+
}
|
|
156
|
+
}
|
|
75
157
|
}
|
|
76
158
|
}
|
|
77
159
|
|
|
@@ -96,11 +178,112 @@ function determineStep (type) {
|
|
|
96
178
|
}
|
|
97
179
|
|
|
98
180
|
export default ({ highlightBorder = false, wrapperStyle = null, holdingPen, label, placeholder, property, required, pattern, type, autofocus, valueContext, permanentTopPlaceholder = false, permanentTopLabel = false, disabled, maxCharacters, maxNumber, minNumber, darkBackground, onkeyup, oninput, onchange }) => {
|
|
181
|
+
// Generate a stable wrapper id per holdingPen+property to enable DOM measurement for dynamic positioning
|
|
182
|
+
const TF_ID_MAP_SYMBOL = Symbol.for('stalefish.textfield.idMap')
|
|
183
|
+
function ensureId (holding, prop) {
|
|
184
|
+
if (!holding) return `sf-textfield-${prop || Math.random().toString(36).slice(2)}`
|
|
185
|
+
if (!holding[TF_ID_MAP_SYMBOL]) {
|
|
186
|
+
Object.defineProperty(holding, TF_ID_MAP_SYMBOL, { value: {}, enumerable: false })
|
|
187
|
+
}
|
|
188
|
+
const map = holding[TF_ID_MAP_SYMBOL]
|
|
189
|
+
if (!map[prop]) {
|
|
190
|
+
map[prop] = `sf-textfield-${prop}-${Math.random().toString(36).slice(2)}`
|
|
191
|
+
}
|
|
192
|
+
return map[prop]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const wrapperId = ensureId(holdingPen, property)
|
|
99
196
|
const normalizedType = determineType(type)
|
|
100
|
-
const isColor = normalizedType === 'color'
|
|
101
197
|
const isNumber = normalizedType === 'number'
|
|
198
|
+
const isInteger = typeof type === 'string' && type.toLowerCase() === 'integer'
|
|
199
|
+
// Hide valueContext when type is color (design requirement)
|
|
200
|
+
const hasValueContext = !!valueContext && normalizedType !== 'color'
|
|
201
|
+
|
|
202
|
+
// Increment/decrement logic for custom controls
|
|
203
|
+
const coerce = (val) => {
|
|
204
|
+
if (val === '' || val === null || val === undefined) return null
|
|
205
|
+
const n = Number(val)
|
|
206
|
+
return Number.isFinite(n) ? n : null
|
|
207
|
+
}
|
|
208
|
+
// Derive step details and use scaled integer math to avoid FP artifacts (e.g., 3.3000000000000003)
|
|
209
|
+
const stepStr = determineStep(normalizedType)
|
|
210
|
+
const stepDp = isInteger ? 0 : (String(stepStr).includes('.') ? (String(stepStr).split('.')[1] || '').length : 0)
|
|
211
|
+
const scale = Math.pow(10, stepDp)
|
|
212
|
+
const stepInt = isInteger ? 1 : Math.round(Number(stepStr) * scale) // integer number of scaled units per step
|
|
213
|
+
const clamp = (n) => {
|
|
214
|
+
if (typeof maxNumber === 'number' && n > maxNumber) n = maxNumber
|
|
215
|
+
if (typeof minNumber === 'number' && n < minNumber) n = minNumber
|
|
216
|
+
return n
|
|
217
|
+
}
|
|
218
|
+
const setValue = (newVal) => {
|
|
219
|
+
// Provide a faux event compatible with formField and our guarded change()
|
|
220
|
+
const faux = { currentTarget: { value: String(newVal), validity: { valid: true } } }
|
|
221
|
+
change({ e: faux, holdingPen, property, label: styles.label })
|
|
222
|
+
onchange && onchange(faux)
|
|
223
|
+
rerender()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Briefly emphasize focus styling when interacting via custom arrows
|
|
227
|
+
const pulseActive = () => {
|
|
228
|
+
if (typeof window === 'undefined') return
|
|
229
|
+
setTimeout(() => {
|
|
230
|
+
const el = document.getElementById(`${wrapperId}-input`)
|
|
231
|
+
if (!el || el.disabled) return
|
|
232
|
+
try { el.focus && el.focus({ preventScroll: true }) } catch (e) {}
|
|
233
|
+
el.classList && el.classList.add(styles.activeFocus)
|
|
234
|
+
setTimeout(() => { el && el.classList && el.classList.remove(styles.activeFocus) }, 400)
|
|
235
|
+
}, 0)
|
|
236
|
+
}
|
|
237
|
+
const inc = () => {
|
|
238
|
+
const cur = coerce(holdingPen[property])
|
|
239
|
+
const base = cur == null ? (typeof minNumber === 'number' ? minNumber : 0) : cur
|
|
240
|
+
let next
|
|
241
|
+
if (isInteger) {
|
|
242
|
+
next = Math.trunc(base + 1)
|
|
243
|
+
} else {
|
|
244
|
+
const scaled = Math.round(base * scale)
|
|
245
|
+
next = (scaled + stepInt) / scale
|
|
246
|
+
next = Number(next.toFixed(stepDp))
|
|
247
|
+
}
|
|
248
|
+
next = clamp(next)
|
|
249
|
+
setValue(next)
|
|
250
|
+
pulseActive()
|
|
251
|
+
}
|
|
252
|
+
const dec = () => {
|
|
253
|
+
const cur = coerce(holdingPen[property])
|
|
254
|
+
const base = cur == null ? (typeof minNumber === 'number' ? minNumber : 0) : cur
|
|
255
|
+
let next
|
|
256
|
+
if (isInteger) {
|
|
257
|
+
next = Math.trunc(base - 1)
|
|
258
|
+
} else {
|
|
259
|
+
const scaled = Math.round(base * scale)
|
|
260
|
+
next = (scaled - stepInt) / scale
|
|
261
|
+
next = Number(next.toFixed(stepDp))
|
|
262
|
+
}
|
|
263
|
+
next = clamp(next)
|
|
264
|
+
setValue(next)
|
|
265
|
+
pulseActive()
|
|
266
|
+
}
|
|
102
267
|
|
|
103
|
-
const input = html`<input data-gramm="false"
|
|
268
|
+
const input = html`<input data-gramm="false"
|
|
269
|
+
?disabled=${disabled}
|
|
270
|
+
${maxNumber ? { max: maxNumber } : ''}
|
|
271
|
+
${minNumber ? { min: minNumber } : ''}
|
|
272
|
+
${maxCharacters ? { maxlength: maxCharacters } : ''}
|
|
273
|
+
style="${disabled ? 'cursor: not-allowed; opacity: 0.3;' : ''}"
|
|
274
|
+
id="${wrapperId}-input"
|
|
275
|
+
class="${styles.textfield} ${isNumber ? styles.withRightControls : ''} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''} ${highlightBorder ? styles.highlight : ''}"
|
|
276
|
+
value="${holdingPen[property] !== undefined && holdingPen[property] !== null ? holdingPen[property] : ''}"
|
|
277
|
+
onkeyup=${e => { onkeyup && onkeyup(e); if (isNumber && e && e.key === 'ArrowUp') { e.preventDefault && e.preventDefault(); inc() } else if (isNumber && e && e.key === 'ArrowDown') { e.preventDefault && e.preventDefault(); dec() } }}
|
|
278
|
+
?required=${required}
|
|
279
|
+
onchange=${e => { change({ e, holdingPen, property, label: styles.label }); onchange && onchange(e) }}
|
|
280
|
+
oninput=${e => { change({ e, holdingPen, property, label: styles.label }); oninput && oninput(e) }}
|
|
281
|
+
onblur=${formField(holdingPen, property)}
|
|
282
|
+
placeholder="${placeholder || ''}${required ? ' *' : ''}"
|
|
283
|
+
type="${normalizedType}"
|
|
284
|
+
${pattern ? { pattern } : ''}
|
|
285
|
+
${isNumber ? { step: determineStep(normalizedType) } : ''}
|
|
286
|
+
/>`
|
|
104
287
|
|
|
105
288
|
if (autofocus) {
|
|
106
289
|
input.autofocus = true
|
|
@@ -110,12 +293,53 @@ export default ({ highlightBorder = false, wrapperStyle = null, holdingPen, labe
|
|
|
110
293
|
? (typeof wrapperStyle === 'string' ? wrapperStyle : (wrapperStyle.toString ? wrapperStyle.toString() : ''))
|
|
111
294
|
: ''
|
|
112
295
|
|
|
296
|
+
// Dynamically position the custom up/down controls 10px to the left of valueContext (if present)
|
|
297
|
+
if (typeof window !== 'undefined') {
|
|
298
|
+
const applyLayout = () => {
|
|
299
|
+
if (!hasValueContext) return
|
|
300
|
+
const vc = document.getElementById(`${wrapperId}-vc`)
|
|
301
|
+
const ctrls = document.getElementById(`${wrapperId}-ctrls`)
|
|
302
|
+
const inputEl = document.getElementById(`${wrapperId}-input`)
|
|
303
|
+
if (!vc || !ctrls) return
|
|
304
|
+
const badgeWidth = vc.offsetWidth || 0
|
|
305
|
+
const rightOffset = 7 + badgeWidth + 10 // valueContext right is 7px; keep 10px gap
|
|
306
|
+
ctrls.style.right = rightOffset + 'px'
|
|
307
|
+
if (inputEl) {
|
|
308
|
+
const safety = 10
|
|
309
|
+
const arrowsWidth = 24
|
|
310
|
+
inputEl.style.paddingRight = (rightOffset + arrowsWidth + safety) + 'px'
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Schedule after render
|
|
314
|
+
setTimeout(applyLayout, 0)
|
|
315
|
+
// Observe badge width changes
|
|
316
|
+
setTimeout(() => {
|
|
317
|
+
if (!hasValueContext) return
|
|
318
|
+
const vc = document.getElementById(`${wrapperId}-vc`)
|
|
319
|
+
if (vc && typeof window !== 'undefined' && typeof window.ResizeObserver === 'function') {
|
|
320
|
+
const ro = new window.ResizeObserver(() => applyLayout())
|
|
321
|
+
ro.observe(vc)
|
|
322
|
+
}
|
|
323
|
+
window.addEventListener('resize', applyLayout)
|
|
324
|
+
}, 0)
|
|
325
|
+
}
|
|
326
|
+
|
|
113
327
|
return html`
|
|
114
|
-
<div class="${wrapperClassName}" style="display: inline-block; width: calc(100% - 10px); margin: ${label ? '40' : '5'}px 5px 5px 5px;">
|
|
328
|
+
<div id="${wrapperId}" class="${wrapperClassName}" style="display: inline-block; width: calc(100% - 10px); margin: ${label ? '40' : '5'}px 5px 5px 5px;">
|
|
115
329
|
<label style="width: 100%; text-align: left; position: relative; padding: 0;">
|
|
116
|
-
${
|
|
117
|
-
${label ? html`<span class="${styles.label}" style="opacity: ${holdingPen[property] === 0 || holdingPen[property] || (permanentTopPlaceholder || permanentTopLabel) ? 1 : 0}; font-size: 16px; font-weight: normal; color: #999; margin-left: 5px; padding: 9px; background-color: rgba(255,255,255,${darkBackground ? 1 : 0.8}); ">${label}${required ? ' *' : ''}</span>` : ''}
|
|
330
|
+
${hasValueContext ? html`<div id="${wrapperId}-vc" data-vc class="${styles.valueContext}">${valueContext}</div>` : ''}
|
|
331
|
+
${label ? html`<span class="${styles.label} ${normalizedType === 'color' ? styles.labelColorAdjustForColorType : ''}" style="opacity: ${holdingPen[property] === 0 || holdingPen[property] || (permanentTopPlaceholder || permanentTopLabel) ? 1 : 0}; font-size: 16px; font-weight: normal; color: #999; margin-left: 5px; padding: 9px; background-color: rgba(255,255,255,${darkBackground ? 1 : 0.8}); ">${label}${required ? ' *' : ''}</span>` : ''}
|
|
118
332
|
${input}
|
|
333
|
+
${
|
|
334
|
+
(isNumber && !disabled)
|
|
335
|
+
? html`
|
|
336
|
+
<span id="${wrapperId}-ctrls" class="${styles.controls} ${hasValueContext ? styles.controlsWithValueContext : ''}" aria-hidden="false">
|
|
337
|
+
<button type="button" tabindex="-1" aria-label="Increase value" class="${styles.btn}" style="transform: rotate(180deg);" onmousedown=${(e) => e.preventDefault()} onclick=${inc}>${solidDown({ colour: '#CCC', width: '14px', height: '14px' })}</button>
|
|
338
|
+
<button type="button" tabindex="-1" aria-label="Decrease value" class="${styles.btn}" onmousedown=${(e) => e.preventDefault()} onclick=${dec}>${solidDown({ colour: '#CCC', width: '14px', height: '14px' })}</button>
|
|
339
|
+
</span>
|
|
340
|
+
`
|
|
341
|
+
: ''
|
|
342
|
+
}
|
|
119
343
|
</label>
|
|
120
344
|
</div>
|
|
121
345
|
`
|
|
@@ -23,7 +23,7 @@ const styles = css`
|
|
|
23
23
|
position: relative;
|
|
24
24
|
height: 55px;
|
|
25
25
|
background-color: #FFF;
|
|
26
|
-
color: #
|
|
26
|
+
color: #666;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/* Placeholder text color */
|
|
@@ -86,6 +86,9 @@ const styles = css`
|
|
|
86
86
|
}
|
|
87
87
|
.arrowIcon > svg { display: block; width: 12px; height: 12px; }
|
|
88
88
|
|
|
89
|
+
/* Programmatic visual focus when clicking custom controls */
|
|
90
|
+
.activeFocus { border: solid 5px #969696; }
|
|
91
|
+
|
|
89
92
|
.clear {
|
|
90
93
|
cursor: pointer;
|
|
91
94
|
position: absolute;
|
|
@@ -223,7 +226,33 @@ export default function timePicker ({
|
|
|
223
226
|
if (typeof state.tmpHour !== 'number') state.tmpHour = hm.h
|
|
224
227
|
if (typeof state.tmpMinute !== 'number') state.tmpMinute = hm.min
|
|
225
228
|
|
|
229
|
+
// Emphasize focus styling on the input and keep it until blur
|
|
230
|
+
const pulseActive = () => {
|
|
231
|
+
if (typeof window === 'undefined') return
|
|
232
|
+
setTimeout(() => {
|
|
233
|
+
const el = document.querySelector(`#${wrapperId} [data-input]`)
|
|
234
|
+
if (!el || el.disabled) return
|
|
235
|
+
try { el.focus && el.focus({ preventScroll: true }) } catch (e) {}
|
|
236
|
+
el.classList && el.classList.add(styles.activeFocus)
|
|
237
|
+
// Do not auto-remove; rely on onblur handler below to clear the class
|
|
238
|
+
}, 0)
|
|
239
|
+
}
|
|
240
|
+
|
|
226
241
|
const stepMinute = (delta) => () => {
|
|
242
|
+
// Before stepping, resync internal state from the visible input value if possible.
|
|
243
|
+
// This ensures that after manual typing (e.g., "12" → auto "12:00"), the arrows
|
|
244
|
+
// operate on the currently displayed HH:MM rather than any stale tmp state.
|
|
245
|
+
if (typeof document !== 'undefined') {
|
|
246
|
+
try {
|
|
247
|
+
const el = document.querySelector(`#${wrapperId} [data-input]`)
|
|
248
|
+
const txt = el && typeof el.value === 'string' ? el.value.trim() : ''
|
|
249
|
+
const parsed = parseHM(txt)
|
|
250
|
+
if (parsed && Number.isFinite(parsed.h) && Number.isFinite(parsed.min)) {
|
|
251
|
+
state.tmpHour = Math.max(0, Math.min(23, parsed.h | 0))
|
|
252
|
+
state.tmpMinute = Math.max(0, Math.min(59, parsed.min | 0))
|
|
253
|
+
}
|
|
254
|
+
} catch {}
|
|
255
|
+
}
|
|
227
256
|
// If stepping by 15 minutes, snap to quarters (00, 15, 30, 45) instead of adding to arbitrary minutes
|
|
228
257
|
if (minuteStep === 15) {
|
|
229
258
|
const dir = delta >= 0 ? 1 : -1
|
|
@@ -254,6 +283,7 @@ export default function timePicker ({
|
|
|
254
283
|
const valueStr = `${pad2(h)}:${pad2(m)}`
|
|
255
284
|
commitValue({ holdingPen, property, onchange, valueStr })
|
|
256
285
|
rerender()
|
|
286
|
+
pulseActive()
|
|
257
287
|
return
|
|
258
288
|
}
|
|
259
289
|
|
|
@@ -264,6 +294,7 @@ export default function timePicker ({
|
|
|
264
294
|
const valueStr = `${pad2(state.tmpHour)}:${pad2(state.tmpMinute)}`
|
|
265
295
|
commitValue({ holdingPen, property, onchange, valueStr })
|
|
266
296
|
rerender()
|
|
297
|
+
pulseActive()
|
|
267
298
|
}
|
|
268
299
|
|
|
269
300
|
// Helpers for caret-preserving normalization
|
|
@@ -483,6 +514,10 @@ export default function timePicker ({
|
|
|
483
514
|
}
|
|
484
515
|
} catch (err) { /* ignore */ }
|
|
485
516
|
}
|
|
517
|
+
// Keep internal state in sync so arrow controls operate on the visible value (HH:00)
|
|
518
|
+
// Do not commit yet to avoid disrupting the user's minute entry; arrows will commit when used.
|
|
519
|
+
state.tmpHour = hh
|
|
520
|
+
state.tmpMinute = 0
|
|
486
521
|
// Do not commit yet; allow subsequent typing to edit minutes naturally
|
|
487
522
|
oninput && oninput(e)
|
|
488
523
|
return
|
|
@@ -610,6 +645,7 @@ export default function timePicker ({
|
|
|
610
645
|
if (e) { e.stopPropagation(); e.preventDefault() }
|
|
611
646
|
commitValue({ holdingPen, property, onchange, valueStr: '' })
|
|
612
647
|
rerender()
|
|
648
|
+
pulseActive()
|
|
613
649
|
return false
|
|
614
650
|
}
|
|
615
651
|
|
|
@@ -625,10 +661,19 @@ export default function timePicker ({
|
|
|
625
661
|
${label ? html`<span class="${styles.label}" style="opacity: ${(holdingPen && (holdingPen[property] === 0 || holdingPen[property])) ? 1 : 0}; font-size: 16px; font-weight: normal; color: #999; margin-left: 5px; padding: 9px; background-color: rgba(255,255,255,0.8); position: absolute; top: -36px;">${label}${required ? ' *' : ''}</span>` : ''}
|
|
626
662
|
${!disableClear ? html`<div data-clear class="${styles.clear}" onclick=${clearValue}>clear</div>` : ''}
|
|
627
663
|
<div class="${styles.icon}">${timeIcon({ colour: '#ccc', width: 20, height: 20 })}</div>
|
|
628
|
-
<input data-gramm="false" ?disabled=${disabled} style="${disabled ? 'cursor: not-allowed; opacity: 0.3;' : ''}" class="${styles.textfield} ${styles.withRightIcon} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" ?required=${required}
|
|
664
|
+
<input id="${wrapperId}-input" data-gramm="false" ?disabled=${disabled} style="${disabled ? 'cursor: not-allowed; opacity: 0.3;' : ''}" class="${styles.textfield} ${styles.withRightIcon} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" ?required=${required}
|
|
629
665
|
onchange=${onTypedChange}
|
|
630
666
|
oninput=${onTypedChange}
|
|
631
|
-
|
|
667
|
+
onfocus=${() => {
|
|
668
|
+
const el = typeof document !== 'undefined' && document.querySelector(`#${wrapperId} [data-input]`)
|
|
669
|
+
if (el && el.classList) el.classList.add(styles.activeFocus)
|
|
670
|
+
}}
|
|
671
|
+
onblur=${(ev) => {
|
|
672
|
+
// finalize value and remove active class on blur
|
|
673
|
+
finalizeTypedValue(ev)
|
|
674
|
+
const el = ev && ev.target
|
|
675
|
+
if (el && el.classList) el.classList.remove(styles.activeFocus)
|
|
676
|
+
}}
|
|
632
677
|
onkeydown=${(ev) => {
|
|
633
678
|
if (ev.key === 'ArrowUp') {
|
|
634
679
|
ev.preventDefault()
|
|
@@ -646,11 +691,11 @@ export default function timePicker ({
|
|
|
646
691
|
.value=${displayValue} data-input />
|
|
647
692
|
|
|
648
693
|
<div class="${styles.controls}">
|
|
649
|
-
<button type="button" tabindex="-1" class="${styles.btn}" onclick=${stepMinute(minuteStep)} aria-label="Increase time by ${minuteStep} minutes">
|
|
650
|
-
<span class="${styles.arrowIcon}"
|
|
694
|
+
<button type="button" tabindex="-1" class="${styles.btn}" style="transform: rotate(180deg);" onmousedown=${(e) => e.preventDefault()} onclick=${stepMinute(minuteStep)} aria-label="Increase time by ${minuteStep} minutes">
|
|
695
|
+
<span class="${styles.arrowIcon}">${solidDown({ colour: '#ccc', width: '12', height: '12' })}</span>
|
|
651
696
|
</button>
|
|
652
|
-
<button type="button" tabindex="-1" class="${styles.btn}" onclick=${stepMinute(-minuteStep)} aria-label="Decrease time by ${minuteStep} minutes">
|
|
653
|
-
<span class="${styles.arrowIcon}"
|
|
697
|
+
<button type="button" tabindex="-1" class="${styles.btn}" onmousedown=${(e) => e.preventDefault()} onclick=${stepMinute(-minuteStep)} aria-label="Decrease time by ${minuteStep} minutes">
|
|
698
|
+
<span class="${styles.arrowIcon}">${solidDown({ colour: '#ccc', width: '12', height: '12' })}</span>
|
|
654
699
|
</button>
|
|
655
700
|
</div>
|
|
656
701
|
</div>
|
package/components/uploader.mjs
CHANGED
|
@@ -81,6 +81,11 @@ const styles = css`
|
|
|
81
81
|
z-index: 20;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/* Programmatic visual focus/active indicator to match other fields */
|
|
85
|
+
.activeFocus {
|
|
86
|
+
border: solid 5px #969696;
|
|
87
|
+
}
|
|
88
|
+
|
|
84
89
|
.dragOver {}
|
|
85
90
|
.dragAccept {}
|
|
86
91
|
.dragReject {}
|
|
@@ -129,6 +134,14 @@ function clearAllUploaderFrames () {
|
|
|
129
134
|
})
|
|
130
135
|
} catch {}
|
|
131
136
|
}
|
|
137
|
+
|
|
138
|
+
// Ensure only one uploader shows the active (darker) border at a time
|
|
139
|
+
function clearAllUploaderActiveFocus () {
|
|
140
|
+
try {
|
|
141
|
+
const frames = document.querySelectorAll(`.${styles.frame}`)
|
|
142
|
+
frames.forEach(f => f.classList.remove(styles.activeFocus))
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
132
145
|
/**
|
|
133
146
|
* Functional uploader component compatible with halfcab's lit-html base.
|
|
134
147
|
* No internal instance caching; simply returns a template.
|
|
@@ -288,6 +301,20 @@ export default function uploader (args) {
|
|
|
288
301
|
}
|
|
289
302
|
|
|
290
303
|
// Helpers for drag & drop behavior
|
|
304
|
+
const addActive = (frameEl) => {
|
|
305
|
+
try {
|
|
306
|
+
if (!frameEl || disabled) return
|
|
307
|
+
// Exclusivity: remove active state from all other uploader frames first
|
|
308
|
+
clearAllUploaderActiveFocus()
|
|
309
|
+
frameEl.classList && frameEl.classList.add(styles.activeFocus)
|
|
310
|
+
} catch {}
|
|
311
|
+
}
|
|
312
|
+
const removeActive = (frameEl) => {
|
|
313
|
+
try {
|
|
314
|
+
if (!frameEl) return
|
|
315
|
+
frameEl.classList && frameEl.classList.remove(styles.activeFocus)
|
|
316
|
+
} catch {}
|
|
317
|
+
}
|
|
291
318
|
const parseAcceptList = (acceptStr) => {
|
|
292
319
|
if (!acceptStr) return null
|
|
293
320
|
return acceptStr.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
|
|
@@ -404,6 +431,8 @@ export default function uploader (args) {
|
|
|
404
431
|
frame.classList.add(styles.dragOver)
|
|
405
432
|
frame.classList.add(styles.dragReject)
|
|
406
433
|
}
|
|
434
|
+
// Always show active border while a drag is over the frame
|
|
435
|
+
addActive(frame)
|
|
407
436
|
}
|
|
408
437
|
|
|
409
438
|
const onDragEnter = (e) => {
|
|
@@ -417,6 +446,8 @@ export default function uploader (args) {
|
|
|
417
446
|
frame.classList.add(styles.dragOver)
|
|
418
447
|
frame.classList.add(styles.dragReject)
|
|
419
448
|
}
|
|
449
|
+
// Indicate active when drag enters
|
|
450
|
+
addActive(frame)
|
|
420
451
|
}
|
|
421
452
|
|
|
422
453
|
const onDragLeave = (e) => {
|
|
@@ -427,6 +458,8 @@ export default function uploader (args) {
|
|
|
427
458
|
if (!related || !frame.contains(related)) {
|
|
428
459
|
// Remove local reject indicators; global will maintain accept state
|
|
429
460
|
frame.classList.remove(styles.dragReject)
|
|
461
|
+
// Remove active indicator when leaving drop zone
|
|
462
|
+
removeActive(frame)
|
|
430
463
|
}
|
|
431
464
|
}
|
|
432
465
|
|
|
@@ -437,6 +470,8 @@ export default function uploader (args) {
|
|
|
437
470
|
// Clear all global highlights so every frame returns to neutral on drop
|
|
438
471
|
clearAllUploaderFrames()
|
|
439
472
|
const frame = e.currentTarget
|
|
473
|
+
// Keep active state after a successful drop
|
|
474
|
+
addActive(frame)
|
|
440
475
|
const input = frame.querySelector('input[type="file"]')
|
|
441
476
|
const files = e.dataTransfer && e.dataTransfer.files
|
|
442
477
|
let ok = true
|
|
@@ -473,6 +508,8 @@ export default function uploader (args) {
|
|
|
473
508
|
} else {
|
|
474
509
|
addDragClass(frame, styles.dragReject)
|
|
475
510
|
setTimeout(() => clearDragClasses(frame), 180)
|
|
511
|
+
// If rejected, also remove active state shortly after to reflect error
|
|
512
|
+
setTimeout(() => removeActive(frame), 180)
|
|
476
513
|
}
|
|
477
514
|
}
|
|
478
515
|
|
|
@@ -529,6 +566,8 @@ export default function uploader (args) {
|
|
|
529
566
|
if (!inputEl || disabled) return
|
|
530
567
|
const frame = inputEl.closest ? inputEl.closest(`.${styles.frame}`) : inputEl.parentElement
|
|
531
568
|
if (!frame || frame.dataset.opening === '1') return
|
|
569
|
+
// Add active border immediately while chooser is opening
|
|
570
|
+
addActive(frame)
|
|
532
571
|
const placeholderEl = frame.querySelector(`.${styles.placeholder}`)
|
|
533
572
|
if (!placeholderEl) return // only show when placeholder is currently visible
|
|
534
573
|
|
|
@@ -566,10 +605,25 @@ export default function uploader (args) {
|
|
|
566
605
|
<div class="${wrapperClassName}" style="display: inline-block; width: 100%; margin-top: 36px;">
|
|
567
606
|
<label style="width: 100%; text-align: left; position: relative; padding: 0; cursor: pointer;">
|
|
568
607
|
${label ? html`<span class="${styles.label}" style="opacity: ${holdingPen[property] === 0 || holdingPen[property] || (permanentTopPlaceholder || permanentTopLabel) ? 1 : 0};">${label}${required ? ' *' : ''}</span>` : ''}
|
|
569
|
-
<span class="${styles.frame}" data-accept="${acceptAttr || ''}" data-disabled="${!!disabled}" ondragenter=${onDragEnter} ondragover=${onDragOver} ondragleave=${onDragLeave} ondrop=${onDrop} onmousedown=${(e) => {
|
|
608
|
+
<span class="${styles.frame}" style="color: ${holdingPen[property] ? '#666' : '#999'};" data-accept="${acceptAttr || ''}" data-disabled="${!!disabled}" ondragenter=${onDragEnter} ondragover=${onDragOver} ondragleave=${onDragLeave} ondrop=${onDrop} onmousedown=${(e) => {
|
|
570
609
|
if (disabled) return
|
|
571
610
|
try {
|
|
572
611
|
const frame = e.currentTarget
|
|
612
|
+
// Prevent the wrapper from losing active state when clicking internal controls
|
|
613
|
+
e.preventDefault && e.preventDefault()
|
|
614
|
+
addActive(frame)
|
|
615
|
+
// Remove active state if user clicks outside the label area next time
|
|
616
|
+
const labelEl = frame && frame.closest('label')
|
|
617
|
+
const onDocDown = (ev) => {
|
|
618
|
+
try {
|
|
619
|
+
if (!labelEl) return
|
|
620
|
+
const target = ev && ev.target
|
|
621
|
+
if (target && labelEl.contains(target)) return
|
|
622
|
+
removeActive(frame)
|
|
623
|
+
document.removeEventListener('mousedown', onDocDown, true)
|
|
624
|
+
} catch {}
|
|
625
|
+
}
|
|
626
|
+
setTimeout(() => document.addEventListener('mousedown', onDocDown, true), 0)
|
|
573
627
|
const input = frame && frame.querySelector('input[type="file"]')
|
|
574
628
|
showOpeningWhileChoosing(input)
|
|
575
629
|
} catch {}
|
|
@@ -588,21 +642,34 @@ export default function uploader (args) {
|
|
|
588
642
|
<div
|
|
589
643
|
class="${styles.clear}"
|
|
590
644
|
role="button"
|
|
591
|
-
tabindex="
|
|
645
|
+
tabindex="-1"
|
|
646
|
+
onmousedown=${(e) => {
|
|
647
|
+
// Prevent focus from moving to the clear control; keep focus/active on the frame
|
|
648
|
+
e.preventDefault()
|
|
649
|
+
e.stopPropagation()
|
|
650
|
+
}}
|
|
592
651
|
onclick=${(e) => {
|
|
593
652
|
// Prevent the label's default behavior of triggering the hidden file input
|
|
594
653
|
e.preventDefault()
|
|
595
654
|
e.stopPropagation()
|
|
655
|
+
try {
|
|
656
|
+
const frame = e.currentTarget && e.currentTarget.closest && e.currentTarget.closest('label') && e.currentTarget.closest('label').querySelector('.' + styles.frame)
|
|
657
|
+
addActive(frame)
|
|
658
|
+
} catch {}
|
|
596
659
|
// Only clear when there is a value; otherwise do nothing
|
|
597
660
|
if (holdingPen && Object.prototype.hasOwnProperty.call(holdingPen, property) && holdingPen[property]) {
|
|
598
661
|
if (typeof onclear === 'function') onclear(e)
|
|
599
662
|
}
|
|
600
663
|
}}
|
|
601
664
|
onkeydown=${(e) => {
|
|
602
|
-
//
|
|
665
|
+
// Keep handler for programmatic focus cases; Tab will not reach here due to tabindex -1
|
|
603
666
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
604
667
|
e.preventDefault()
|
|
605
668
|
e.stopPropagation()
|
|
669
|
+
try {
|
|
670
|
+
const frame = e.currentTarget && e.currentTarget.closest && e.currentTarget.closest('label') && e.currentTarget.closest('label').querySelector('.' + styles.frame)
|
|
671
|
+
addActive(frame)
|
|
672
|
+
} catch {}
|
|
606
673
|
if (holdingPen && Object.prototype.hasOwnProperty.call(holdingPen, property) && holdingPen[property]) {
|
|
607
674
|
if (typeof onclear === 'function') onclear(e)
|
|
608
675
|
}
|
package/example/app.mjs
CHANGED
|
@@ -7,7 +7,9 @@ const styles = css`
|
|
|
7
7
|
.card { padding: 12px; border: 1px solid #8884; border-radius: 8px; background: #efefef; }
|
|
8
8
|
.title { margin: 0 0 8px; font-size: 16px; font-weight: 600; }
|
|
9
9
|
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
|
10
|
-
.subtle { font-size: 12px; opacity: 0.7; }
|
|
10
|
+
.subtle { font-size: 12px; opacity: 0.7; },
|
|
11
|
+
.textfieldSelectBox { width: 260px }
|
|
12
|
+
.variantSelectFull { width: 100%; }
|
|
11
13
|
`
|
|
12
14
|
|
|
13
15
|
// Demo form state
|
|
@@ -43,12 +45,12 @@ const Showcase = () => html`
|
|
|
43
45
|
<div class="${styles.card}">
|
|
44
46
|
<div class="${styles.title}">Text field</div>
|
|
45
47
|
<div class="${styles.row}">
|
|
46
|
-
<
|
|
47
|
-
<span class="${styles.subtle}">variant</span>
|
|
48
|
+
<div style="width: 100%;">
|
|
48
49
|
${sf.selectbox({
|
|
49
50
|
holdingPen: state.demo,
|
|
50
51
|
property: 'textType',
|
|
51
|
-
label: '
|
|
52
|
+
label: 'Variant',
|
|
53
|
+
wrapperStyle: styles.variantSelectFull,
|
|
52
54
|
options: [
|
|
53
55
|
{ name: 'string', value: 'string' },
|
|
54
56
|
{ name: 'number', value: 'number' },
|
|
@@ -73,15 +75,17 @@ const Showcase = () => html`
|
|
|
73
75
|
rerender()
|
|
74
76
|
}
|
|
75
77
|
})}
|
|
76
|
-
</
|
|
78
|
+
</div>
|
|
77
79
|
</div>
|
|
78
80
|
${sf.textfield({
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
holdingPen: state.demo,
|
|
82
|
+
property: 'text',
|
|
83
|
+
label: 'Value',
|
|
84
|
+
placeholder: state.demo.textType === 'color' ? '#rrggbb' : (state.demo.textType === 'number' || state.demo.textType === 'integer' ? 'enter a number' : 'e.g. Ada Lovelace'),
|
|
85
|
+
type: state.demo.textType,
|
|
86
|
+
valueContext: 'longer',
|
|
87
|
+
wrapperStyle: styles.textfieldSelectBox
|
|
88
|
+
})}
|
|
85
89
|
</div>
|
|
86
90
|
|
|
87
91
|
<div class="${styles.card}">
|
package/example/index.html
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"shifty-router/create-location.js": "https://esm.sh/shifty-router@0.1.1/create-location.js",
|
|
16
16
|
|
|
17
17
|
"lit": "https://esm.sh/lit@3.3.2",
|
|
18
|
+
"lit/directives/unsafe-html.js": "https://esm.sh/lit@3.3.2/directives/unsafe-html.js",
|
|
18
19
|
"@lit-labs/ssr-client": "https://esm.sh/@lit-labs/ssr-client@1.1.8",
|
|
19
20
|
"@lit-labs/ssr": "/stalefish/example/ssr-browser-stub.js",
|
|
20
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stalefish",
|
|
3
|
-
"version": "8.1.
|
|
3
|
+
"version": "8.1.13",
|
|
4
4
|
"description": "Simple function based component library for halfcab tagged template literals",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"module": "index.mjs",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"homepage": "https://github.com/lorengreenfield/stalefish#readme",
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"halfcab": "^15.0.
|
|
34
|
+
"halfcab": "^15.0.4",
|
|
35
35
|
"standard": "^12.0.1"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|