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.
@@ -1,6 +1,6 @@
1
1
  import { html, css, formField, fieldIsTouched } from 'halfcab'
2
2
 
3
- let styles = css`
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: #999;
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
- let 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} onchange=${e => {
34
- formField(holdingPen, property)(e)
35
- onchange && onchange(e)
36
- }} type="checkbox" ?required=${required} />`
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: #999;
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}" style="transform: rotate(90deg);">${solidDown({ colour: '#ccc' })}</span>
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}" style="transform: rotate(180deg);">${solidDown({ colour: '#ccc' })}</span>
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}" style="transform: rotate(0deg);">${solidDown({ colour: '#ccc' })}</span>
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}" style="transform: rotate(270deg);">${solidDown({ colour: '#ccc' })}</span>
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 }; rerender() },
316
- onNextMonth: () => { if (state.viewMonth === 11) { state.viewMonth = 0; state.viewYear += 1 } else { state.viewMonth += 1 }; rerender() },
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 } : ''}
@@ -36,7 +36,7 @@ const styles = css`
36
36
  left: 5px;
37
37
  font-size: 16px;
38
38
  font-weight: normal;
39
- color: #999;
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} : ''}>${required ? 'Select an option' : ''}</option>
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>
@@ -2,7 +2,7 @@ import { html, css, formField, fieldIsTouched } from 'halfcab'
2
2
 
3
3
  let styles = css`
4
4
  .textarea {
5
- color: #999;
5
+ color: #666;
6
6
  padding: 10px;
7
7
  border: solid 5px #c9c9c9;
8
8
  transition: border 0.3s;
@@ -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: #999;
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
- line-height: 1.2em;
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: 10px;
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
- const closestLabel = e.target.closest('label')
69
- const labelEl = closestLabel.querySelector(label.selector)
70
- if (labelEl) {
71
- if (holdingPen[property] === 0 || holdingPen[property]) {
72
- labelEl.style.opacity = 1
73
- } else {
74
- labelEl.style.opacity = 0
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" ?disabled=${disabled} ${maxNumber ? { max: maxNumber } : ''} ${minNumber ? { min: minNumber } : ''} ${maxCharacters ? { maxlength: maxCharacters } : ''} style="${isColor ? 'height: 50px; padding: 2px 3px;' : ''}${disabled ? 'cursor: not-allowed; opacity: 0.3;' : ''}" class="${styles.textfield} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''} ${highlightBorder ? styles.highlight : ''}" value="${holdingPen[property] !== undefined && holdingPen[property] !== null ? holdingPen[property] : ''}" onkeyup=${e => onkeyup && onkeyup(e)} ?required=${required} onchange=${e => { change({ e, holdingPen, property, label: styles.label }); onchange && onchange(e) }} oninput=${e => { change({ e, holdingPen, property, label: styles.label }); oninput && oninput(e) }} onblur=${formField(holdingPen, property)} placeholder="${placeholder || ''}${required ? ' *' : ''}" type="${normalizedType}" ${pattern ? { pattern } : ''} ${isNumber ? { step: determineStep(normalizedType) } : ''} />`
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
- ${valueContext ? html`<div class="${styles.valueContext}">${valueContext}</div>` : ''}
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: #999;
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
- onblur=${finalizeTypedValue}
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}" style="transform: rotate(180deg); display:inline-block;">${solidDown({ colour: '#ccc' })}</span>
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}" style="transform: rotate(0deg); display:inline-block;">${solidDown({ colour: '#ccc' })}</span>
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>
@@ -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="0"
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
- // Also handle keyboard activation without triggering the file chooser
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
- <label style="display:flex; align-items:center; gap:8px;">
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: 'Type',
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
- </label>
78
+ </div>
77
79
  </div>
78
80
  ${sf.textfield({
79
- holdingPen: state.demo,
80
- property: 'text',
81
- label: 'Value',
82
- placeholder: state.demo.textType === 'color' ? '#rrggbb' : (state.demo.textType === 'number' || state.demo.textType === 'integer' ? 'enter a number' : 'e.g. Ada Lovelace'),
83
- type: state.demo.textType
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}">
@@ -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.2",
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.3",
34
+ "halfcab": "^15.0.4",
35
35
  "standard": "^12.0.1"
36
36
  },
37
37
  "dependencies": {