stalefish 8.0.10 → 8.0.11

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.
@@ -0,0 +1,239 @@
1
+ import { html, css, formField, fieldIsTouched, rerender } from 'halfcab'
2
+
3
+ const styles = css`
4
+ .textfield {
5
+ padding: 10px;
6
+ border: solid 5px #c9c9c9;
7
+ transition: border 0.3s;
8
+ outline: none;
9
+ width: 100%;
10
+ font-size: 18px;
11
+ border-radius: 0;
12
+ box-shadow: none !important;
13
+ font-weight: normal;
14
+ box-sizing: border-box;
15
+ font-family: inherit;
16
+ line-height: 1.4em;
17
+ -webkit-appearance: none;
18
+ -moz-appearance: none;
19
+ appearance: none;
20
+ z-index: 20;
21
+ position: relative;
22
+ height: 55px;
23
+ background-color: #FFF;
24
+ }
25
+
26
+ .label {
27
+ transition: opacity 0.75s;
28
+ border-top-right-radius: 5px;
29
+ border-top-left-radius: 5px;
30
+ user-select: none;
31
+ position: absolute;
32
+ top: -55px;
33
+ z-index: 10;
34
+ }
35
+
36
+ .clear {
37
+ cursor: pointer;
38
+ position: absolute;
39
+ color: #AAA;
40
+ font-size: 0.8em;
41
+ line-height: 0.9em;
42
+ font-weight: normal;
43
+ box-sizing: border-box;
44
+ right: 40px;
45
+ background-color: #EEE;
46
+ padding: 5px 10px;
47
+ z-index: 30;
48
+ border-radius: 3px;
49
+ top: 17px;
50
+ }
51
+ `
52
+
53
+ const STATE_SYMBOL = Symbol.for('stalefish.date.state')
54
+ const ID_MAP_SYMBOL = Symbol.for('stalefish.date.idMap')
55
+
56
+ function pad2 (n) { return (n < 10 ? '0' : '') + n }
57
+ function toYMD (d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` }
58
+ function parseYMD (str) {
59
+ if (!str || typeof str !== 'string') return null
60
+ const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(str)
61
+ if (!m) return null
62
+ const y = parseInt(m[1], 10)
63
+ const mon = parseInt(m[2], 10) - 1
64
+ const day = parseInt(m[3], 10)
65
+ const d = new Date(y, mon, day)
66
+ if (d.getFullYear() !== y || d.getMonth() !== mon || d.getDate() !== day) return null
67
+ return d
68
+ }
69
+ function daysInMonth (y, m) { return new Date(y, m + 1, 0).getDate() }
70
+
71
+ function ensureState (holdingPen, property) {
72
+ if (!holdingPen) return {}
73
+ if (!holdingPen[STATE_SYMBOL]) {
74
+ Object.defineProperty(holdingPen, STATE_SYMBOL, { value: {}, enumerable: false })
75
+ }
76
+ const map = holdingPen[STATE_SYMBOL]
77
+ if (!map[property]) {
78
+ map[property] = { open: false, viewMonth: null, viewYear: null, tmpDate: null }
79
+ }
80
+ return map[property]
81
+ }
82
+
83
+ function ensureId (holdingPen, property, uniqueKey) {
84
+ if (!holdingPen) return `sf-date-${property || Math.random().toString(36).slice(2)}`
85
+ if (!holdingPen[ID_MAP_SYMBOL]) {
86
+ Object.defineProperty(holdingPen, ID_MAP_SYMBOL, { value: {}, enumerable: false })
87
+ }
88
+ const map = holdingPen[ID_MAP_SYMBOL]
89
+ if (!map[property]) {
90
+ map[property] = `sf-date-${property}-${Math.random().toString(36).slice(2)}`
91
+ }
92
+ return `sf-date-${uniqueKey || map[property]}`
93
+ }
94
+
95
+ function buildCalendarUI ({ state, today, onPickDay, onPrevMonth, onNextMonth }) {
96
+ const y = state.viewYear
97
+ const m = state.viewMonth
98
+ const first = new Date(y, m, 1)
99
+ const startWeekday = first.getDay() // 0 Sun - 6 Sat
100
+ const dim = daysInMonth(y, m)
101
+ const cells = []
102
+ for (let i = 0; i < startWeekday; i++) cells.push(null)
103
+ for (let d = 1; d <= dim; d++) cells.push(d)
104
+ while (cells.length % 7 !== 0) cells.push(null)
105
+ const rows = []
106
+ for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7))
107
+ const header = html`<div style="display:flex; justify-content:space-between; align-items:center; padding: 6px 8px;">
108
+ <button type="button" onclick=${onPrevMonth} aria-label="Previous month">‹</button>
109
+ <div>${new Date(y, m, 1).toLocaleString(undefined, { month: 'long', year: 'numeric' })}</div>
110
+ <button type="button" onclick=${onNextMonth} aria-label="Next month">›</button>
111
+ </div>`
112
+ const weekdayRow = html`<div style="display:grid; grid-template-columns: repeat(7, 1fr); gap:4px; padding: 0 8px; opacity:0.7; font-size:12px;">
113
+ ${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => html`<div style="text-align:center;">${d}</div>`)}
114
+ </div>`
115
+ const grid = html`<div style="display:grid; grid-template-columns: repeat(7, 1fr); gap:4px; padding: 8px;">
116
+ ${rows.map(week => week.map(d => {
117
+ if (!d) return html`<div></div>`
118
+ const isToday = today.getFullYear() === y && today.getMonth() === m && today.getDate() === d
119
+ const isSelected = state.tmpDate && state.tmpDate.getFullYear() === y && state.tmpDate.getMonth() === m && state.tmpDate.getDate() === d
120
+ return html`<button type="button" onclick=${() => onPickDay(d)} style="padding:6px; ${isSelected ? 'background:#007aff; color:#fff;' : ''} ${isToday ? 'border:1px solid #007aff;' : 'border:1px solid #ddd;'}">${d}</button>`
121
+ }))}
122
+ </div>`
123
+ return html`<div>${header}${weekdayRow}${grid}</div>`
124
+ }
125
+
126
+ function commitValue ({ holdingPen, property, onchange, valueStr }) {
127
+ const fauxE = { currentTarget: { validity: { valid: true }, value: valueStr } }
128
+ formField(holdingPen, property)(fauxE)
129
+ onchange && onchange(fauxE)
130
+ }
131
+
132
+ export default function datePicker ({
133
+ wrapperStyle = null,
134
+ holdingPen,
135
+ label,
136
+ placeholder,
137
+ property,
138
+ required,
139
+ pattern,
140
+ disabled,
141
+ disableClear = false,
142
+ onchange,
143
+ oninput,
144
+ uniqueKey
145
+ } = {}) {
146
+ const wrapperId = ensureId(holdingPen, property, uniqueKey)
147
+ const state = ensureState(holdingPen, property)
148
+
149
+ const currentStr = holdingPen && property ? holdingPen[property] : ''
150
+ const now = new Date()
151
+ if (state.viewMonth === null || state.viewYear === null) {
152
+ const baseDate = parseYMD(currentStr) || now
153
+ state.viewMonth = baseDate.getMonth()
154
+ state.viewYear = baseDate.getFullYear()
155
+ }
156
+ if (!state.tmpDate) {
157
+ state.tmpDate = parseYMD(currentStr) || new Date(now.getFullYear(), now.getMonth(), now.getDate())
158
+ }
159
+
160
+ const commit = () => {
161
+ const valueStr = state.tmpDate ? toYMD(state.tmpDate) : ''
162
+ commitValue({ holdingPen, property, onchange, valueStr })
163
+ state.open = false
164
+ rerender()
165
+ }
166
+
167
+ const clearValue = (e) => {
168
+ if (e) { e.stopPropagation(); e.preventDefault() }
169
+ commitValue({ holdingPen, property, onchange, valueStr: '' })
170
+ state.open = false
171
+ rerender()
172
+ return false
173
+ }
174
+
175
+ const onInputChange = (e) => {
176
+ // let them type YYYY-MM-DD; commit only if parsing succeeds
177
+ const v = (e?.target?.value || '').trim()
178
+ const d = parseYMD(v)
179
+ if (d) {
180
+ state.tmpDate = d
181
+ commit()
182
+ } else {
183
+ oninput && oninput(e)
184
+ }
185
+ }
186
+
187
+ const open = (e) => {
188
+ if (disabled) return
189
+ e && e.stopPropagation()
190
+ // Only toggle open on click to avoid focus+click double toggle flicker
191
+ state.open = !state.open
192
+ rerender()
193
+ if (typeof window !== 'undefined' && state.open) {
194
+ const closeOnOutside = (ev) => {
195
+ const wrapperEl = document.getElementById(wrapperId)
196
+ if (!wrapperEl) return
197
+ if (!wrapperEl.contains(ev.target)) {
198
+ state.open = false
199
+ rerender()
200
+ document.removeEventListener('mousedown', closeOnOutside, true)
201
+ }
202
+ }
203
+ // Defer attaching to next tick to avoid capturing the very click that opened
204
+ setTimeout(() => document.addEventListener('mousedown', closeOnOutside, true), 0)
205
+ }
206
+ }
207
+
208
+ const popup = state.open
209
+ ? html`<div role="dialog" aria-modal="false" style="position:absolute; z-index: 9999; top: 55px; left: 0; background: #fff; border: 1px solid #ddd; box-shadow: 0 2px 10px rgba(0,0,0,.15); padding: 4px;">
210
+ ${buildCalendarUI({
211
+ state,
212
+ today: new Date(),
213
+ onPickDay: (d) => { state.tmpDate = new Date(state.viewYear, state.viewMonth, d); rerender() },
214
+ onPrevMonth: () => { if (state.viewMonth === 0) { state.viewMonth = 11; state.viewYear -= 1 } else { state.viewMonth -= 1 }; rerender() },
215
+ onNextMonth: () => { if (state.viewMonth === 11) { state.viewMonth = 0; state.viewYear += 1 } else { state.viewMonth += 1 }; rerender() }
216
+ })}
217
+ <div style="display:flex; justify-content:flex-end; padding: 8px;"><button type="button" onclick=${commit}>Apply</button></div>
218
+ </div>`
219
+ : ''
220
+
221
+ const displayValue = currentStr || ''
222
+
223
+ return html`
224
+ <div id="${wrapperId}" class="${wrapperStyle}" style="min-height: 55px; display: inline-block; width: calc(100% - 10px); margin: 40px 5px 5px 5px;">
225
+ <div style="display: inline-block; width: 100%; text-align: left; position: relative; padding: 0;" onclick=${e => e.stopPropagation()}>
226
+ ${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>` : ''}
227
+ ${!disableClear ? html`<div data-clear class="${styles.clear}" onclick=${clearValue}>clear</div>` : ''}
228
+ <input data-gramm="false" ?disabled=${disabled} style="${disabled ? 'cursor: not-allowed; opacity: 0.3;' : ''}" class="${styles.textfield} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" ${required ? { required: 'required' } : ''}
229
+ onclick=${open}
230
+ onchange=${onInputChange}
231
+ oninput=${onInputChange}
232
+ placeholder="${(placeholder || 'Date') + (required ? ' *' : '')}"
233
+ type="text" ${pattern ? { pattern } : ''}
234
+ .value=${displayValue} data-input />
235
+ ${popup}
236
+ </div>
237
+ </div>
238
+ `
239
+ }
@@ -1,357 +1,21 @@
1
- import { html, css, formField, fieldIsTouched, rerender } from 'halfcab'
2
- import calendarIcon from './icons/calendarIcon.mjs'
3
- import timeIcon from './icons/timeIcon.mjs'
4
-
5
- const styles = css`
6
- .textfield {
7
- padding: 10px;
8
- border: solid 5px #c9c9c9;
9
- transition: border 0.3s;
10
- outline: none;
11
- width: 100%;
12
- font-size: 18px;
13
- border-radius: 0;
14
- box-shadow: none !important;
15
- font-weight: normal;
16
- box-sizing: border-box;
17
- font-family: inherit;
18
- line-height: 1.4em;
19
- -webkit-appearance: none;
20
- -moz-appearance: none;
21
- appearance: none;
22
- z-index: 20;
23
- position: relative;
24
- height: 55px;
25
- background-color: #FFF;
26
- }
27
-
28
- .label {
29
- transition: opacity 0.75s;
30
- border-top-right-radius: 5px;
31
- border-top-left-radius: 5px;
32
- user-select: none;
33
- position: absolute;
34
- top: -55px;
35
- z-index: 10;
36
- }
37
-
38
- .textfield:focus {
39
- border: solid 5px #969696;
40
- }
41
-
42
- .textfield::placeholder {
43
- color: #999;
44
- }
45
-
46
- .textfield.touched:invalid:not(:focus) {
47
- border-color: red;
48
- }
49
-
50
- .icon {
51
- position: absolute;
52
- pointer-events: none;
53
- color: #c9c9c9;
54
- right: 12px;
55
- font-size: 2em;
56
- z-index: 30;
57
- position: absolute;
58
- top: 9px;
59
- }
60
-
61
- .clear {
62
- cursor: pointer;
63
- position: absolute;
64
- color: #AAA;
65
- font-size: 0.8em;
66
- line-height: 0.9em;
67
- font-weight: normal;
68
- box-sizing: border-box;
69
- top: -1px;
70
- right: 40px;
71
- background-color: #EEE;
72
- padding: 5px 10px;
73
- z-index: 30;
74
- border-radius: 3px;
75
- position: absolute;
76
- top: 17px;
77
- }
78
- `
79
-
80
- function change ({ e, holdingPen, property }) {
81
- const ff = formField(holdingPen, property)(e)
82
- // e.target.focus()
83
- return ff
84
- }
85
-
86
- // Touchscreen detection is no longer needed in the native picker
87
-
88
- // --------------------
89
- // Native date/time picker implementation (no external libs)
90
- // --------------------
91
-
92
- const STATE_SYMBOL = Symbol.for('stalefish.dtp.state')
93
- const ID_MAP_SYMBOL = Symbol.for('stalefish.dtp.idMap')
94
-
95
- function pad2 (n) { return (n < 10 ? '0' : '') + n }
96
- function toYMD (d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` }
97
- // function toHM (d) { return `${pad2(d.getHours())}:${pad2(d.getMinutes())}` }
98
- function parseYMD (str) {
99
- if (!str || typeof str !== 'string') return null
100
- const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(str)
101
- if (!m) return null
102
- const y = parseInt(m[1], 10)
103
- const mon = parseInt(m[2], 10) - 1
104
- const day = parseInt(m[3], 10)
105
- const d = new Date(y, mon, day)
106
- if (d.getFullYear() !== y || d.getMonth() !== mon || d.getDate() !== day) return null
107
- return d
108
- }
109
- function parseHM (str) {
110
- if (!str || typeof str !== 'string') return null
111
- const m = /^([0-9]{1,2}):([0-9]{2})$/.exec(str)
112
- if (!m) return null
113
- const h = parseInt(m[1], 10)
114
- const min = parseInt(m[2], 10)
115
- if (h < 0 || h > 23 || min < 0 || min > 59) return null
116
- return { h, min }
117
- }
118
- function daysInMonth (y, m) { return new Date(y, m + 1, 0).getDate() }
119
-
120
- function ensureState (holdingPen, property) {
121
- if (!holdingPen) return {}
122
- if (!holdingPen[STATE_SYMBOL]) {
123
- Object.defineProperty(holdingPen, STATE_SYMBOL, { value: {}, enumerable: false })
124
- }
125
- const map = holdingPen[STATE_SYMBOL]
126
- if (!map[property]) {
127
- map[property] = { open: false, viewMonth: null, viewYear: null, tmpDate: null, tmpHour: 0, tmpMinute: 0 }
128
- }
129
- return map[property]
130
- }
131
-
132
- function ensureId (holdingPen, property, uniqueKey) {
133
- if (!holdingPen) return `sf-dtp-${property || Math.random().toString(36).slice(2)}`
134
- if (!holdingPen[ID_MAP_SYMBOL]) {
135
- Object.defineProperty(holdingPen, ID_MAP_SYMBOL, { value: {}, enumerable: false })
136
- }
137
- const map = holdingPen[ID_MAP_SYMBOL]
138
- if (!map[property]) {
139
- map[property] = `sf-dtp-${property}-${Math.random().toString(36).slice(2)}`
140
- }
141
- return `sf-dtp-${uniqueKey || map[property]}`
142
- }
143
-
144
- function commitValue ({ mode, holdingPen, property, onchange, valueStr }) {
145
- const fauxE = { currentTarget: { validity: { valid: true }, value: valueStr } }
146
- formField(holdingPen, property)(fauxE)
147
- onchange && onchange(fauxE)
148
- }
149
-
150
- function buildTimeUI ({ state, commit, minuteStep = 1 }) {
151
- const inc = (key, delta, max) => () => {
152
- state[key] = (state[key] + delta + (max + 1)) % (max + 1)
153
- rerender()
154
- }
155
- const setFromInput = (key, max) => e => {
156
- const v = parseInt(e.target.value, 10)
157
- if (!isNaN(v) && v >= 0 && v <= max) {
158
- state[key] = v
159
- rerender()
160
- }
161
- }
162
- const stepMinute = (delta) => () => {
163
- state.tmpMinute = (state.tmpMinute + delta + 60) % 60
164
- rerender()
165
- }
166
- return html`
167
- <div style="display:flex; gap: 12px; align-items: center; padding: 8px;">
168
- <div style="display:flex; flex-direction:column; align-items:center;">
169
- <button type="button" onclick=${inc('tmpHour', 1, 23)} aria-label="Increase hour">▲</button>
170
- <input style="width:3em; text-align:center;" value=${pad2(state.tmpHour)} oninput=${setFromInput('tmpHour', 23)} />
171
- <button type="button" onclick=${inc('tmpHour', -1, 23)} aria-label="Decrease hour">▼</button>
172
- </div>
173
- <div>:</div>
174
- <div style="display:flex; flex-direction:column; align-items:center;">
175
- <button type="button" onclick=${stepMinute(minuteStep)} aria-label="Increase minute">▲</button>
176
- <input style="width:3em; text-align:center;" value=${pad2(state.tmpMinute)} oninput=${setFromInput('tmpMinute', 59)} />
177
- <button type="button" onclick=${stepMinute(-minuteStep)} aria-label="Decrease minute">▼</button>
178
- </div>
179
- <button type="button" style="margin-left:auto;" onclick=${commit}>Apply</button>
180
- </div>`
181
- }
182
-
183
- function buildCalendarUI ({ state, today, onPickDay, onPrevMonth, onNextMonth }) {
184
- const y = state.viewYear
185
- const m = state.viewMonth
186
- const first = new Date(y, m, 1)
187
- const startWeekday = first.getDay() // 0 Sun - 6 Sat
188
- const dim = daysInMonth(y, m)
189
- const cells = []
190
- for (let i = 0; i < startWeekday; i++) cells.push(null)
191
- for (let d = 1; d <= dim; d++) cells.push(d)
192
- while (cells.length % 7 !== 0) cells.push(null)
193
- const rows = []
194
- for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7))
195
- const header = html`<div style="display:flex; justify-content:space-between; align-items:center; padding: 6px 8px;">
196
- <button type="button" onclick=${onPrevMonth} aria-label="Previous month">‹</button>
197
- <div>${new Date(y, m, 1).toLocaleString(undefined, { month: 'long', year: 'numeric' })}</div>
198
- <button type="button" onclick=${onNextMonth} aria-label="Next month">›</button>
199
- </div>`
200
- const weekdayRow = html`<div style="display:grid; grid-template-columns: repeat(7, 1fr); gap:4px; padding: 0 8px; opacity:0.7; font-size:12px;">
201
- ${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => html`<div style="text-align:center;">${d}</div>`)}
202
- </div>`
203
- const grid = html`<div style="display:grid; grid-template-columns: repeat(7, 1fr); gap:4px; padding: 8px;">
204
- ${rows.map(week => week.map(d => {
205
- if (!d) return html`<div></div>`
206
- const isToday = today.getFullYear() === y && today.getMonth() === m && today.getDate() === d
207
- const isSelected = state.tmpDate && state.tmpDate.getFullYear() === y && state.tmpDate.getMonth() === m && state.tmpDate.getDate() === d
208
- return html`<button type="button" onclick=${() => onPickDay(d)} style="padding:6px; ${isSelected ? 'background:#007aff; color:#fff;' : ''} ${isToday ? 'border:1px solid #007aff;' : 'border:1px solid #ddd;'}">${d}</button>`
209
- }))}
210
- </div>`
211
- return html`<div>${header}${weekdayRow}${grid}</div>`
212
- }
213
-
214
- export default function dateTimePicker ({
215
- wrapperStyle = null,
216
- holdingPen,
217
- label,
218
- placeholder,
219
- property,
220
- required,
221
- pattern,
222
- permanentTopPlaceholder = true,
223
- permanentTopLabel = false,
224
- flatpickrConfig = {}, // ignored for compatibility
225
- timeOnly = false,
226
- dateTime = false, // new optional combined mode
227
- disabled,
228
- disableClear = false,
229
- onchange,
230
- oninput,
231
- uniqueKey
232
- } = {}) {
233
- // Initialize per-field state and id
234
- const state = ensureState(holdingPen, property)
235
- const wrapperId = ensureId(holdingPen, property, uniqueKey)
236
-
237
- // Derive current value and seed temp state if needed
238
- const currentStr = holdingPen && property ? holdingPen[property] : ''
239
- const now = new Date()
240
- const openMode = timeOnly ? 'time' : (dateTime ? 'datetime' : 'date')
241
- if (state.viewMonth === null || state.viewYear === null) {
242
- const baseDate = parseYMD(currentStr) || now
243
- state.viewMonth = baseDate.getMonth()
244
- state.viewYear = baseDate.getFullYear()
245
- }
246
- if (!state.tmpDate) {
247
- state.tmpDate = parseYMD(currentStr) || new Date(now.getFullYear(), now.getMonth(), now.getDate())
248
- }
249
- const hm = parseHM(currentStr?.split(' ')?.[1] || (!timeOnly && !dateTime ? '' : currentStr)) || { h: now.getHours(), min: now.getMinutes() }
250
- if (typeof state.tmpHour !== 'number') state.tmpHour = hm.h
251
- if (typeof state.tmpMinute !== 'number') state.tmpMinute = hm.min
252
-
253
- const icon = timeOnly ? timeIcon({ colour: '#ccc' }) : calendarIcon({ colour: '#ccc' })
254
-
255
- const commit = () => {
256
- let valueStr = ''
257
- if (openMode === 'time') {
258
- valueStr = `${pad2(state.tmpHour)}:${pad2(state.tmpMinute)}`
259
- } else if (openMode === 'date') {
260
- valueStr = toYMD(state.tmpDate)
261
- } else {
262
- valueStr = `${toYMD(state.tmpDate)} ${pad2(state.tmpHour)}:${pad2(state.tmpMinute)}`
1
+ import datePicker from './datePicker.mjs'
2
+ import timePicker from './timePicker.mjs'
3
+
4
+ // Delegator component for backward compatibility.
5
+ // New usage is to import and use datePicker or timePicker directly.
6
+ export default function dateTimePicker (props = {}) {
7
+ const { timeOnly, dateTime } = props
8
+ if (timeOnly) {
9
+ // Inline controls, no popup
10
+ return timePicker({ ...props })
11
+ }
12
+ if (dateTime) {
13
+ if (typeof console !== 'undefined' && console && console.warn) {
14
+ console.warn('stalefish/dateTimePicker: combined dateTime mode is deprecated. Use separate datePicker and timePicker fields.')
263
15
  }
264
- commitValue({ mode: openMode, holdingPen, property, onchange, valueStr })
265
- state.open = false
266
- rerender()
16
+ // Fall back to showing just the date portion to avoid breaking UIs.
17
+ return datePicker({ ...props })
267
18
  }
268
-
269
- const clearValue = (e) => {
270
- if (e) {
271
- e.stopPropagation()
272
- e.preventDefault()
273
- }
274
- commitValue({ mode: openMode, holdingPen, property, onchange, valueStr: '' })
275
- state.open = false
276
- rerender()
277
- return false
278
- }
279
-
280
- const onInputChange = (e) => {
281
- change({ e, holdingPen, property, label: styles.label })
282
- onchange && onchange(e)
283
- }
284
-
285
- const toggleOpen = (e) => {
286
- if (disabled) return
287
- e && e.stopPropagation()
288
- state.open = !state.open
289
- rerender()
290
- if (typeof window !== 'undefined' && state.open) {
291
- // Click outside to close
292
- const closeOnOutside = (ev) => {
293
- const wrapperEl = document.getElementById(wrapperId)
294
- if (!wrapperEl) return
295
- if (!wrapperEl.contains(ev.target)) {
296
- state.open = false
297
- rerender()
298
- document.removeEventListener('mousedown', closeOnOutside, true)
299
- }
300
- }
301
- document.addEventListener('mousedown', closeOnOutside, true)
302
- }
303
- }
304
-
305
- // Compute displayed value
306
- const displayValue = (() => {
307
- if (!currentStr) return ''
308
- return currentStr
309
- })()
310
-
311
- const calendarPart = (() => {
312
- if (openMode === 'time') return html``
313
- return buildCalendarUI({
314
- state,
315
- today: new Date(),
316
- onPickDay: (d) => { state.tmpDate = new Date(state.viewYear, state.viewMonth, d); rerender() },
317
- onPrevMonth: () => {
318
- if (state.viewMonth === 0) { state.viewMonth = 11; state.viewYear -= 1 } else { state.viewMonth -= 1 }
319
- rerender()
320
- },
321
- onNextMonth: () => {
322
- if (state.viewMonth === 11) { state.viewMonth = 0; state.viewYear += 1 } else { state.viewMonth += 1 }
323
- rerender()
324
- }
325
- })
326
- })()
327
-
328
- const timePart = (() => {
329
- if (openMode === 'date') {
330
- return html`<div style="display:flex; justify-content:flex-end; padding: 8px;"><button type="button" onclick=${commit}>Apply</button></div>`
331
- }
332
- return buildTimeUI({ state, commit, minuteStep: 1 })
333
- })()
334
-
335
- const popup = state.open
336
- ? html`<div role="dialog" aria-modal="false" style="position:absolute; z-index: 9999; top: 55px; left: 0; background: #fff; border: 1px solid #ddd; box-shadow: 0 2px 10px rgba(0,0,0,.15); padding: 4px;">${calendarPart}${timePart}</div>`
337
- : ''
338
-
339
- return html`
340
- <div id="${wrapperId}" class="${wrapperStyle}" style="min-height: 55px; display: inline-block; width: calc(100% - 10px); margin: 40px 5px 5px 5px;">
341
- <div style="display: inline-block; width: 100%; text-align: left; position: relative; padding: 0;" onclick=${e => e.stopPropagation()}>
342
- <div class="${styles.icon}">${icon}</div>
343
- ${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,0.8); position: absolute; top: -36px;">${label}${required ? ' *' : ''}</span>` : ''}
344
- ${!disableClear ? html`<div data-clear class="${styles.clear}" onclick=${clearValue}>clear</div>` : ''}
345
- <input data-gramm="false" ?disabled=${disabled} style="${disabled ? 'cursor: not-allowed; opacity: 0.3;' : ''}" class="${styles.textfield} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" ${required ? { required: 'required' } : ''}
346
- onclick=${toggleOpen}
347
- onfocus=${toggleOpen}
348
- onchange=${onInputChange}
349
- oninput=${e => { e.target.defaultValue = ''; oninput && oninput(e) }}
350
- placeholder="${placeholder || ''}${required ? ' *' : ''}"
351
- type="text" ${pattern ? { pattern } : ''}
352
- .value=${displayValue} data-input />
353
- ${popup}
354
- </div>
355
- </div>
356
- `
19
+ // Default: date-only picker with popup
20
+ return datePicker({ ...props })
357
21
  }
@@ -0,0 +1,198 @@
1
+ import { html, css, formField, fieldIsTouched, rerender } from 'halfcab'
2
+
3
+ const styles = css`
4
+ .textfield {
5
+ padding: 10px;
6
+ border: solid 5px #c9c9c9;
7
+ transition: border 0.3s;
8
+ outline: none;
9
+ width: 100%;
10
+ font-size: 18px;
11
+ border-radius: 0;
12
+ box-shadow: none !important;
13
+ font-weight: normal;
14
+ box-sizing: border-box;
15
+ font-family: inherit;
16
+ line-height: 1.4em;
17
+ -webkit-appearance: none;
18
+ -moz-appearance: none;
19
+ appearance: none;
20
+ z-index: 20;
21
+ position: relative;
22
+ height: 55px;
23
+ background-color: #FFF;
24
+ }
25
+
26
+ .label {
27
+ transition: opacity 0.75s;
28
+ border-top-right-radius: 5px;
29
+ border-top-left-radius: 5px;
30
+ user-select: none;
31
+ position: absolute;
32
+ top: -55px;
33
+ z-index: 10;
34
+ }
35
+
36
+ .controls {
37
+ position: absolute;
38
+ right: 10px;
39
+ top: 12px;
40
+ display: flex;
41
+ gap: 6px;
42
+ z-index: 30;
43
+ }
44
+
45
+ .btn {
46
+ cursor: pointer;
47
+ background: #eee;
48
+ border: 1px solid #ddd;
49
+ border-radius: 3px;
50
+ padding: 6px 8px;
51
+ line-height: 1;
52
+ }
53
+
54
+ .clear {
55
+ cursor: pointer;
56
+ position: absolute;
57
+ color: #AAA;
58
+ font-size: 0.8em;
59
+ line-height: 0.9em;
60
+ font-weight: normal;
61
+ box-sizing: border-box;
62
+ right: 72px;
63
+ background-color: #EEE;
64
+ padding: 5px 10px;
65
+ z-index: 30;
66
+ border-radius: 3px;
67
+ top: 17px;
68
+ }
69
+ `
70
+
71
+ const STATE_SYMBOL = Symbol.for('stalefish.time.state')
72
+ const ID_MAP_SYMBOL = Symbol.for('stalefish.time.idMap')
73
+
74
+ function pad2 (n) { return (n < 10 ? '0' : '') + n }
75
+ function parseHM (str) {
76
+ if (!str || typeof str !== 'string') return null
77
+ const m = /^([0-9]{1,2}):([0-9]{2})$/.exec(str)
78
+ if (!m) return null
79
+ const h = parseInt(m[1], 10)
80
+ const min = parseInt(m[2], 10)
81
+ if (h < 0 || h > 23 || min < 0 || min > 59) return null
82
+ return { h, min }
83
+ }
84
+
85
+ function ensureState (holdingPen, property) {
86
+ if (!holdingPen) return {}
87
+ if (!holdingPen[STATE_SYMBOL]) {
88
+ Object.defineProperty(holdingPen, STATE_SYMBOL, { value: {}, enumerable: false })
89
+ }
90
+ const map = holdingPen[STATE_SYMBOL]
91
+ if (!map[property]) {
92
+ map[property] = { tmpHour: 0, tmpMinute: 0 }
93
+ }
94
+ return map[property]
95
+ }
96
+
97
+ function ensureId (holdingPen, property, uniqueKey) {
98
+ if (!holdingPen) return `sf-time-${property || Math.random().toString(36).slice(2)}`
99
+ if (!holdingPen[ID_MAP_SYMBOL]) {
100
+ Object.defineProperty(holdingPen, ID_MAP_SYMBOL, { value: {}, enumerable: false })
101
+ }
102
+ const map = holdingPen[ID_MAP_SYMBOL]
103
+ if (!map[property]) {
104
+ map[property] = `sf-time-${property}-${Math.random().toString(36).slice(2)}`
105
+ }
106
+ return `sf-time-${uniqueKey || map[property]}`
107
+ }
108
+
109
+ function commitValue ({ holdingPen, property, onchange, valueStr }) {
110
+ const fauxE = { currentTarget: { validity: { valid: true }, value: valueStr } }
111
+ formField(holdingPen, property)(fauxE)
112
+ onchange && onchange(fauxE)
113
+ }
114
+
115
+ export default function timePicker ({
116
+ wrapperStyle = null,
117
+ holdingPen,
118
+ label,
119
+ placeholder,
120
+ property,
121
+ required,
122
+ pattern,
123
+ disabled,
124
+ disableClear = false,
125
+ minuteStep = 15,
126
+ onchange,
127
+ oninput,
128
+ uniqueKey
129
+ } = {}) {
130
+ const wrapperId = ensureId(holdingPen, property, uniqueKey)
131
+ const state = ensureState(holdingPen, property)
132
+
133
+ const currentStr = holdingPen && property ? holdingPen[property] : ''
134
+ const now = new Date()
135
+ const hm = parseHM(currentStr) || { h: now.getHours(), min: Math.floor(now.getMinutes() / minuteStep) * minuteStep }
136
+ if (typeof state.tmpHour !== 'number') state.tmpHour = hm.h
137
+ if (typeof state.tmpMinute !== 'number') state.tmpMinute = hm.min
138
+
139
+ const stepMinute = (delta) => () => {
140
+ state.tmpMinute = (state.tmpMinute + delta + 60) % 60
141
+ const valueStr = `${pad2(state.tmpHour)}:${pad2(state.tmpMinute)}`
142
+ commitValue({ holdingPen, property, onchange, valueStr })
143
+ rerender()
144
+ }
145
+
146
+ const stepHour = (delta) => () => {
147
+ state.tmpHour = (state.tmpHour + delta + 24) % 24
148
+ const valueStr = `${pad2(state.tmpHour)}:${pad2(state.tmpMinute)}`
149
+ commitValue({ holdingPen, property, onchange, valueStr })
150
+ rerender()
151
+ }
152
+
153
+ const onTypedChange = (e) => {
154
+ // Allow free typing of HH:mm
155
+ const v = (e?.target?.value || '').trim()
156
+ const parsed = parseHM(v)
157
+ if (parsed) {
158
+ state.tmpHour = parsed.h
159
+ state.tmpMinute = parsed.min
160
+ commitValue({ holdingPen, property, onchange, valueStr: `${pad2(parsed.h)}:${pad2(parsed.min)}` })
161
+ rerender()
162
+ } else {
163
+ // Still forward input for external listeners if needed
164
+ oninput && oninput(e)
165
+ }
166
+ }
167
+
168
+ const clearValue = (e) => {
169
+ if (e) { e.stopPropagation(); e.preventDefault() }
170
+ commitValue({ holdingPen, property, onchange, valueStr: '' })
171
+ rerender()
172
+ return false
173
+ }
174
+
175
+ const displayValue = holdingPen && property ? (holdingPen[property] || '') : ''
176
+
177
+ return html`
178
+ <div id="${wrapperId}" class="${wrapperStyle}" style="min-height: 55px; display: inline-block; width: calc(100% - 10px); margin: 40px 5px 5px 5px;">
179
+ <div style="display: inline-block; width: 100%; text-align: left; position: relative; padding: 0;">
180
+ ${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>` : ''}
181
+ ${!disableClear ? html`<div data-clear class="${styles.clear}" onclick=${clearValue}>clear</div>` : ''}
182
+ <input data-gramm="false" ?disabled=${disabled} style="${disabled ? 'cursor: not-allowed; opacity: 0.3;' : ''}" class="${styles.textfield} ${fieldIsTouched(holdingPen, property) === true ? styles.touched : ''}" ${required ? { required: 'required' } : ''}
183
+ onchange=${onTypedChange}
184
+ oninput=${onTypedChange}
185
+ placeholder="${(placeholder || 'Time') + (required ? ' *' : '')}"
186
+ type="text" ${pattern ? { pattern } : ''}
187
+ .value=${displayValue} data-input />
188
+
189
+ <div class="${styles.controls}">
190
+ <button type="button" class="${styles.btn}" onclick=${stepHour(1)} aria-label="Increase hour">+1h</button>
191
+ <button type="button" class="${styles.btn}" onclick=${stepHour(-1)} aria-label="Decrease hour">-1h</button>
192
+ <button type="button" class="${styles.btn}" onclick=${stepMinute(minuteStep)} aria-label="Increase minutes">+${minuteStep}m</button>
193
+ <button type="button" class="${styles.btn}" onclick=${stepMinute(-minuteStep)} aria-label="Decrease minutes">-${minuteStep}m</button>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ `
198
+ }
package/index.mjs CHANGED
@@ -12,6 +12,8 @@ import toolbar from './components/toolbar.mjs'
12
12
  import fab from './components/fab.mjs'
13
13
  import table from './components/table.mjs'
14
14
  import dateTimePicker from './components/dateTimePicker.mjs'
15
+ import datePicker from './components/datePicker.mjs'
16
+ import timePicker from './components/timePicker.mjs'
15
17
  import moreVertical from './components/icons/moreVertical.mjs'
16
18
  import close from './components/icons/close.mjs'
17
19
  import up from './components/icons/up.mjs'
@@ -39,6 +41,8 @@ export {
39
41
  fab,
40
42
  table,
41
43
  dateTimePicker,
44
+ datePicker,
45
+ timePicker,
42
46
  calendarIcon,
43
47
  timeIcon,
44
48
  solidDown,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stalefish",
3
- "version": "8.0.10",
3
+ "version": "8.0.11",
4
4
  "description": "Simple function based component library for halfcab tagged template literals",
5
5
  "main": "index.mjs",
6
6
  "module": "index.mjs",