@tachui/forms 0.7.1-alpha → 0.8.1-alpha
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/README.md +87 -272
- package/dist/DatePicker-D5nRFTUm.js +475 -0
- package/dist/DatePicker-D5nRFTUm.js.map +1 -0
- package/dist/Select-yZyKooXk.js +945 -0
- package/dist/Select-yZyKooXk.js.map +1 -0
- package/dist/Slider-0-oal5YR.js +644 -0
- package/dist/Slider-0-oal5YR.js.map +1 -0
- package/dist/TextField-hX15dY3U.js +509 -0
- package/dist/TextField-hX15dY3U.js.map +1 -0
- package/dist/components/advanced/Slider.d.ts +190 -0
- package/dist/components/advanced/Slider.d.ts.map +1 -0
- package/dist/components/advanced/Stepper.d.ts +161 -0
- package/dist/components/advanced/Stepper.d.ts.map +1 -0
- package/dist/components/advanced/index.d.ts +15 -0
- package/dist/components/advanced/index.d.ts.map +1 -0
- package/dist/components/advanced/index.js +6 -0
- package/dist/components/advanced/index.js.map +1 -0
- package/dist/components/date-picker/DatePicker.d.ts +126 -0
- package/dist/components/date-picker/DatePicker.d.ts.map +1 -0
- package/dist/components/date-picker/index.d.ts +14 -0
- package/dist/components/date-picker/index.d.ts.map +1 -0
- package/dist/components/date-picker/index.js +5 -0
- package/dist/components/date-picker/index.js.map +1 -0
- package/dist/components/form-container/index.d.ts +58 -0
- package/dist/components/form-container/index.d.ts.map +1 -0
- package/dist/components/selection/Checkbox.d.ts.map +1 -0
- package/dist/components/selection/Radio.d.ts.map +1 -0
- package/dist/components/selection/Select.d.ts.map +1 -0
- package/dist/components/selection/index.d.ts +68 -0
- package/dist/components/selection/index.d.ts.map +1 -0
- package/dist/components/selection/index.js +12 -0
- package/dist/components/selection/index.js.map +1 -0
- package/dist/components/text-input/TextField.d.ts.map +1 -0
- package/dist/components/text-input/index.d.ts +8 -0
- package/dist/components/text-input/index.d.ts.map +1 -0
- package/dist/components/text-input/index.js +18 -0
- package/dist/components/text-input/index.js.map +1 -0
- package/dist/{state/index.js → index-D3WfkqVv.js} +15 -8
- package/dist/index-D3WfkqVv.js.map +1 -0
- package/dist/index.d.ts +10 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +198 -376
- package/dist/index.js.map +1 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/index.d.ts +19 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/validation/component-validation.d.ts +11 -2
- package/dist/validation/component-validation.d.ts.map +1 -1
- package/dist/validation/index.d.ts.map +1 -1
- package/dist/validation/index.js +282 -191
- package/dist/validation/index.js.map +1 -0
- package/package.json +53 -39
- package/src/components/advanced/Slider.ts +722 -0
- package/src/components/advanced/Stepper.ts +715 -0
- package/src/components/advanced/index.ts +20 -0
- package/src/components/date-picker/DatePicker.ts +925 -0
- package/src/components/date-picker/index.ts +20 -0
- package/src/components/form-container/index.ts +266 -0
- package/src/components/selection/Checkbox.ts +478 -0
- package/src/components/selection/Radio.ts +470 -0
- package/src/components/selection/Select.ts +620 -0
- package/src/components/selection/index.ts +81 -0
- package/src/components/text-input/TextField.ts +728 -0
- package/src/components/text-input/index.ts +35 -0
- package/src/index.ts +48 -0
- package/src/state/index.ts +544 -0
- package/src/types/index.ts +579 -0
- package/src/utils/formatters.ts +184 -0
- package/src/utils/index.ts +57 -0
- package/src/validation/component-validation.ts +429 -0
- package/src/validation/index.ts +641 -0
- package/dist/TextField-CGBM3x7K.js +0 -1799
- package/dist/components/Form.d.ts +0 -76
- package/dist/components/Form.d.ts.map +0 -1
- package/dist/components/index.d.ts +0 -9
- package/dist/components/index.d.ts.map +0 -1
- package/dist/components/index.js +0 -28
- package/dist/components/input/Checkbox.d.ts.map +0 -1
- package/dist/components/input/Radio.d.ts.map +0 -1
- package/dist/components/input/Select.d.ts.map +0 -1
- package/dist/components/input/TextField.d.ts.map +0 -1
- package/dist/components/input/index.d.ts +0 -11
- package/dist/components/input/index.d.ts.map +0 -1
- package/dist/utils/validators.d.ts +0 -101
- package/dist/utils/validators.d.ts.map +0 -1
- /package/dist/components/{input → selection}/Checkbox.d.ts +0 -0
- /package/dist/components/{input → selection}/Radio.d.ts +0 -0
- /package/dist/components/{input → selection}/Select.d.ts +0 -0
- /package/dist/components/{input → text-input}/TextField.d.ts +0 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stepper Component (TachUI)
|
|
3
|
+
*
|
|
4
|
+
* SwiftUI-inspired stepper component for numeric input with increment/decrement controls.
|
|
5
|
+
* Provides bounded value adjustment with customizable step intervals and formatting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ModifiableComponent, ModifierBuilder } from '@tachui/core'
|
|
9
|
+
import { createEffect, getSignalImpl, isSignal } from '@tachui/core'
|
|
10
|
+
import type { Signal } from '@tachui/core'
|
|
11
|
+
import { h } from '@tachui/core'
|
|
12
|
+
import type { ComponentInstance, ComponentProps, DOMNode } from '@tachui/core'
|
|
13
|
+
import { withModifiers } from '@tachui/core'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stepper value types - supports integers and floating point numbers
|
|
17
|
+
*/
|
|
18
|
+
export type StepperValue = number
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stepper component properties
|
|
22
|
+
*/
|
|
23
|
+
export interface StepperProps extends ComponentProps {
|
|
24
|
+
// Core properties
|
|
25
|
+
title?: string
|
|
26
|
+
value: Signal<StepperValue> | StepperValue
|
|
27
|
+
|
|
28
|
+
// Range and stepping
|
|
29
|
+
minimumValue?: StepperValue
|
|
30
|
+
maximumValue?: StepperValue
|
|
31
|
+
step?: StepperValue
|
|
32
|
+
|
|
33
|
+
// Custom actions (alternative to value binding)
|
|
34
|
+
onIncrement?: () => void
|
|
35
|
+
onDecrement?: () => void
|
|
36
|
+
|
|
37
|
+
// Event handling
|
|
38
|
+
onChange?: (value: StepperValue) => void
|
|
39
|
+
onEditingChanged?: (editing: boolean) => void
|
|
40
|
+
|
|
41
|
+
// Behavior
|
|
42
|
+
disabled?: boolean | Signal<boolean>
|
|
43
|
+
allowsEmptyValue?: boolean
|
|
44
|
+
|
|
45
|
+
// Formatting
|
|
46
|
+
valueFormatter?: (value: StepperValue) => string
|
|
47
|
+
displayValueInLabel?: boolean
|
|
48
|
+
|
|
49
|
+
// Accessibility
|
|
50
|
+
accessibilityLabel?: string
|
|
51
|
+
accessibilityHint?: string
|
|
52
|
+
incrementAccessibilityLabel?: string
|
|
53
|
+
decrementAccessibilityLabel?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stepper theme configuration
|
|
58
|
+
*/
|
|
59
|
+
export interface StepperTheme {
|
|
60
|
+
colors: {
|
|
61
|
+
background: string
|
|
62
|
+
border: string
|
|
63
|
+
buttonBackground: string
|
|
64
|
+
buttonHover: string
|
|
65
|
+
buttonPress: string
|
|
66
|
+
buttonDisabled: string
|
|
67
|
+
text: string
|
|
68
|
+
buttonText: string
|
|
69
|
+
disabledText: string
|
|
70
|
+
focusRing: string
|
|
71
|
+
}
|
|
72
|
+
spacing: {
|
|
73
|
+
padding: number
|
|
74
|
+
gap: number
|
|
75
|
+
borderRadius: number
|
|
76
|
+
buttonSize: number
|
|
77
|
+
}
|
|
78
|
+
typography: {
|
|
79
|
+
labelSize: number
|
|
80
|
+
buttonSize: number
|
|
81
|
+
labelWeight: string
|
|
82
|
+
buttonWeight: string
|
|
83
|
+
fontFamily: string
|
|
84
|
+
}
|
|
85
|
+
transitions: {
|
|
86
|
+
duration: number
|
|
87
|
+
easing: string
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Default Stepper theme
|
|
93
|
+
*/
|
|
94
|
+
const defaultStepperTheme: StepperTheme = {
|
|
95
|
+
colors: {
|
|
96
|
+
background: '#FFFFFF',
|
|
97
|
+
border: '#D1D1D6',
|
|
98
|
+
buttonBackground: '#F2F2F7',
|
|
99
|
+
buttonHover: '#E5E5EA',
|
|
100
|
+
buttonPress: '#D1D1D6',
|
|
101
|
+
buttonDisabled: '#F2F2F7',
|
|
102
|
+
text: '#000000',
|
|
103
|
+
buttonText: '#007AFF',
|
|
104
|
+
disabledText: '#8E8E93',
|
|
105
|
+
focusRing: '#007AFF',
|
|
106
|
+
},
|
|
107
|
+
spacing: {
|
|
108
|
+
padding: 12,
|
|
109
|
+
gap: 8,
|
|
110
|
+
borderRadius: 8,
|
|
111
|
+
buttonSize: 32,
|
|
112
|
+
},
|
|
113
|
+
typography: {
|
|
114
|
+
labelSize: 16,
|
|
115
|
+
buttonSize: 18,
|
|
116
|
+
labelWeight: '400',
|
|
117
|
+
buttonWeight: '600',
|
|
118
|
+
fontFamily:
|
|
119
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
120
|
+
},
|
|
121
|
+
transitions: {
|
|
122
|
+
duration: 150,
|
|
123
|
+
easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)',
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Stepper component implementation
|
|
129
|
+
*/
|
|
130
|
+
export class StepperComponent implements ComponentInstance<StepperProps> {
|
|
131
|
+
public readonly type = 'component' as const
|
|
132
|
+
public readonly id: string
|
|
133
|
+
public readonly props: StepperProps
|
|
134
|
+
private theme: StepperTheme = defaultStepperTheme
|
|
135
|
+
private incrementButton: HTMLElement | null = null
|
|
136
|
+
private decrementButton: HTMLElement | null = null
|
|
137
|
+
private isEditing = false
|
|
138
|
+
private longPressTimer: ReturnType<typeof setTimeout> | null = null
|
|
139
|
+
private longPressInterval: ReturnType<typeof setInterval> | null = null
|
|
140
|
+
|
|
141
|
+
constructor(props: StepperProps) {
|
|
142
|
+
this.props = props
|
|
143
|
+
this.id = `stepper-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private resolveValue<T>(value: T | Signal<T>): T {
|
|
147
|
+
return isSignal(value) ? value() : value
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private getValue(): StepperValue {
|
|
151
|
+
return this.resolveValue(this.props.value)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private setValue(newValue: StepperValue): void {
|
|
155
|
+
const constrainedValue = this.constrainValue(newValue)
|
|
156
|
+
|
|
157
|
+
if (isSignal(this.props.value)) {
|
|
158
|
+
const signalImpl = getSignalImpl(this.props.value as any)
|
|
159
|
+
if (signalImpl) {
|
|
160
|
+
signalImpl.set(constrainedValue)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (this.props.onChange) {
|
|
165
|
+
this.props.onChange(constrainedValue)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private getMinimumValue(): StepperValue {
|
|
170
|
+
return this.props.minimumValue ?? -Infinity
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private getMaximumValue(): StepperValue {
|
|
174
|
+
return this.props.maximumValue ?? Infinity
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private getStep(): StepperValue {
|
|
178
|
+
return this.props.step ?? 1
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private isDisabled(): boolean {
|
|
182
|
+
return this.resolveValue(this.props.disabled ?? false)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private constrainValue(value: StepperValue): StepperValue {
|
|
186
|
+
const min = this.getMinimumValue()
|
|
187
|
+
const max = this.getMaximumValue()
|
|
188
|
+
return Math.min(Math.max(value, min), max)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private canIncrement(): boolean {
|
|
192
|
+
if (this.isDisabled()) return false
|
|
193
|
+
const currentValue = this.getValue()
|
|
194
|
+
const maxValue = this.getMaximumValue()
|
|
195
|
+
return currentValue < maxValue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private canDecrement(): boolean {
|
|
199
|
+
if (this.isDisabled()) return false
|
|
200
|
+
const currentValue = this.getValue()
|
|
201
|
+
const minValue = this.getMinimumValue()
|
|
202
|
+
return currentValue > minValue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private increment(): void {
|
|
206
|
+
if (!this.canIncrement()) return
|
|
207
|
+
|
|
208
|
+
if (this.props.onIncrement) {
|
|
209
|
+
this.props.onIncrement()
|
|
210
|
+
} else {
|
|
211
|
+
const currentValue = this.getValue()
|
|
212
|
+
const step = this.getStep()
|
|
213
|
+
this.setValue(currentValue + step)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private decrement(): void {
|
|
218
|
+
if (!this.canDecrement()) return
|
|
219
|
+
|
|
220
|
+
if (this.props.onDecrement) {
|
|
221
|
+
this.props.onDecrement()
|
|
222
|
+
} else {
|
|
223
|
+
const currentValue = this.getValue()
|
|
224
|
+
const step = this.getStep()
|
|
225
|
+
this.setValue(currentValue - step)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private formatValue(value: StepperValue): string {
|
|
230
|
+
if (this.props.valueFormatter) {
|
|
231
|
+
return this.props.valueFormatter(value)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Default formatting with appropriate decimal places
|
|
235
|
+
if (Number.isInteger(value)) {
|
|
236
|
+
return value.toString()
|
|
237
|
+
} else {
|
|
238
|
+
// For decimal values, show up to 2 decimal places, removing trailing zeros
|
|
239
|
+
return parseFloat(value.toFixed(2)).toString()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private startLongPress(action: () => void): void {
|
|
244
|
+
this.stopLongPress()
|
|
245
|
+
this.setEditing(true)
|
|
246
|
+
|
|
247
|
+
// Initial delay before starting continuous increment
|
|
248
|
+
this.longPressTimer = setTimeout(() => {
|
|
249
|
+
// Start continuous increment/decrement
|
|
250
|
+
this.longPressInterval = setInterval(() => {
|
|
251
|
+
action()
|
|
252
|
+
}, 100) // 100ms interval for smooth continuous changes
|
|
253
|
+
}, 500) // 500ms initial delay
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private stopLongPress(): void {
|
|
257
|
+
if (this.longPressTimer) {
|
|
258
|
+
clearTimeout(this.longPressTimer)
|
|
259
|
+
this.longPressTimer = null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (this.longPressInterval) {
|
|
263
|
+
clearInterval(this.longPressInterval)
|
|
264
|
+
this.longPressInterval = null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this.setEditing(false)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private setEditing(editing: boolean): void {
|
|
271
|
+
if (this.isEditing !== editing) {
|
|
272
|
+
this.isEditing = editing
|
|
273
|
+
if (this.props.onEditingChanged) {
|
|
274
|
+
this.props.onEditingChanged(editing)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private createButton(
|
|
280
|
+
type: 'increment' | 'decrement',
|
|
281
|
+
symbol: string,
|
|
282
|
+
action: () => void,
|
|
283
|
+
canPerformAction: boolean
|
|
284
|
+
): DOMNode {
|
|
285
|
+
const button = h('button', {
|
|
286
|
+
type: 'button',
|
|
287
|
+
disabled: !canPerformAction,
|
|
288
|
+
'aria-label':
|
|
289
|
+
type === 'increment'
|
|
290
|
+
? this.props.incrementAccessibilityLabel || 'Increment'
|
|
291
|
+
: this.props.decrementAccessibilityLabel || 'Decrement',
|
|
292
|
+
style: {
|
|
293
|
+
width: `${this.theme.spacing.buttonSize}px`,
|
|
294
|
+
height: `${this.theme.spacing.buttonSize}px`,
|
|
295
|
+
border: `1px solid ${this.theme.colors.border}`,
|
|
296
|
+
borderRadius: `${this.theme.spacing.borderRadius}px`,
|
|
297
|
+
backgroundColor: canPerformAction
|
|
298
|
+
? this.theme.colors.buttonBackground
|
|
299
|
+
: this.theme.colors.buttonDisabled,
|
|
300
|
+
color: canPerformAction
|
|
301
|
+
? this.theme.colors.buttonText
|
|
302
|
+
: this.theme.colors.disabledText,
|
|
303
|
+
fontSize: `${this.theme.typography.buttonSize}px`,
|
|
304
|
+
fontWeight: this.theme.typography.buttonWeight,
|
|
305
|
+
fontFamily: this.theme.typography.fontFamily,
|
|
306
|
+
cursor: canPerformAction ? 'pointer' : 'not-allowed',
|
|
307
|
+
display: 'flex',
|
|
308
|
+
alignItems: 'center',
|
|
309
|
+
justifyContent: 'center',
|
|
310
|
+
userSelect: 'none',
|
|
311
|
+
outline: 'none',
|
|
312
|
+
transition: `all ${this.theme.transitions.duration}ms ${this.theme.transitions.easing}`,
|
|
313
|
+
touchAction: 'manipulation', // Prevent double-tap zoom on mobile
|
|
314
|
+
},
|
|
315
|
+
onclick: (e: Event) => {
|
|
316
|
+
e.preventDefault()
|
|
317
|
+
if (canPerformAction) {
|
|
318
|
+
action()
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
onmousedown: (e: Event) => {
|
|
322
|
+
e.preventDefault()
|
|
323
|
+
if (canPerformAction) {
|
|
324
|
+
this.startLongPress(action)
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
onmouseup: () => {
|
|
328
|
+
this.stopLongPress()
|
|
329
|
+
},
|
|
330
|
+
ontouchstart: (e: Event) => {
|
|
331
|
+
e.preventDefault()
|
|
332
|
+
if (canPerformAction) {
|
|
333
|
+
this.startLongPress(action)
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
ontouchend: (e: Event) => {
|
|
337
|
+
e.preventDefault()
|
|
338
|
+
this.stopLongPress()
|
|
339
|
+
},
|
|
340
|
+
onmouseenter: (e: Event) => {
|
|
341
|
+
if (canPerformAction) {
|
|
342
|
+
const target = e.target as HTMLElement
|
|
343
|
+
target.style.backgroundColor = this.theme.colors.buttonHover
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
onmouseleave: (e: Event) => {
|
|
347
|
+
const target = e.target as HTMLElement
|
|
348
|
+
target.style.backgroundColor = canPerformAction
|
|
349
|
+
? this.theme.colors.buttonBackground
|
|
350
|
+
: this.theme.colors.buttonDisabled
|
|
351
|
+
this.stopLongPress()
|
|
352
|
+
},
|
|
353
|
+
onkeydown: (e: KeyboardEvent) => {
|
|
354
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
355
|
+
e.preventDefault()
|
|
356
|
+
if (canPerformAction) {
|
|
357
|
+
action()
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
onfocus: (e: Event) => {
|
|
362
|
+
const target = e.target as HTMLElement
|
|
363
|
+
target.style.boxShadow = `0 0 0 2px ${this.theme.colors.focusRing}40`
|
|
364
|
+
},
|
|
365
|
+
onblur: (e: Event) => {
|
|
366
|
+
const target = e.target as HTMLElement
|
|
367
|
+
target.style.boxShadow = 'none'
|
|
368
|
+
this.stopLongPress()
|
|
369
|
+
},
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const buttonDOM = button.element as HTMLElement
|
|
373
|
+
if (buttonDOM) {
|
|
374
|
+
buttonDOM.textContent = symbol
|
|
375
|
+
buttonDOM.setAttribute('tabindex', '0')
|
|
376
|
+
|
|
377
|
+
if (type === 'increment') {
|
|
378
|
+
this.incrementButton = buttonDOM
|
|
379
|
+
} else {
|
|
380
|
+
this.decrementButton = buttonDOM
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return button
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private createLabel(): DOMNode {
|
|
388
|
+
const currentValue = this.getValue()
|
|
389
|
+
const formattedValue = this.formatValue(currentValue)
|
|
390
|
+
|
|
391
|
+
let labelText = this.props.title || ''
|
|
392
|
+
if (this.props.displayValueInLabel !== false && this.props.title) {
|
|
393
|
+
labelText = `${this.props.title}: ${formattedValue}`
|
|
394
|
+
} else if (!this.props.title && this.props.displayValueInLabel !== false) {
|
|
395
|
+
labelText = formattedValue
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const label = h('span', {
|
|
399
|
+
style: {
|
|
400
|
+
fontSize: `${this.theme.typography.labelSize}px`,
|
|
401
|
+
fontWeight: this.theme.typography.labelWeight,
|
|
402
|
+
fontFamily: this.theme.typography.fontFamily,
|
|
403
|
+
color: this.isDisabled()
|
|
404
|
+
? this.theme.colors.disabledText
|
|
405
|
+
: this.theme.colors.text,
|
|
406
|
+
userSelect: 'none',
|
|
407
|
+
display: 'flex',
|
|
408
|
+
alignItems: 'center',
|
|
409
|
+
minHeight: `${this.theme.spacing.buttonSize}px`,
|
|
410
|
+
},
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const labelDOM = label.element as HTMLElement
|
|
414
|
+
if (labelDOM) {
|
|
415
|
+
labelDOM.textContent = labelText
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return label
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
render(): DOMNode {
|
|
422
|
+
const container = h('div', {
|
|
423
|
+
id: this.id,
|
|
424
|
+
'data-component': 'stepper',
|
|
425
|
+
role: 'group',
|
|
426
|
+
'aria-label':
|
|
427
|
+
this.props.accessibilityLabel || this.props.title || 'Numeric stepper',
|
|
428
|
+
'aria-describedby': this.props.accessibilityHint
|
|
429
|
+
? `${this.id}-hint`
|
|
430
|
+
: undefined,
|
|
431
|
+
style: {
|
|
432
|
+
display: 'inline-flex',
|
|
433
|
+
alignItems: 'center',
|
|
434
|
+
gap: `${this.theme.spacing.gap}px`,
|
|
435
|
+
padding: `${this.theme.spacing.padding}px`,
|
|
436
|
+
backgroundColor: this.theme.colors.background,
|
|
437
|
+
border: `1px solid ${this.theme.colors.border}`,
|
|
438
|
+
borderRadius: `${this.theme.spacing.borderRadius}px`,
|
|
439
|
+
fontFamily: this.theme.typography.fontFamily,
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// Create components
|
|
444
|
+
const decrementButton = this.createButton(
|
|
445
|
+
'decrement',
|
|
446
|
+
'−', // Using minus sign (U+2212) instead of hyphen for better visual appearance
|
|
447
|
+
() => this.decrement(),
|
|
448
|
+
this.canDecrement()
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
const label = this.createLabel()
|
|
452
|
+
|
|
453
|
+
const incrementButton = this.createButton(
|
|
454
|
+
'increment',
|
|
455
|
+
'+',
|
|
456
|
+
() => this.increment(),
|
|
457
|
+
this.canIncrement()
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
// Assemble container
|
|
461
|
+
const containerDOM = container.element as HTMLElement
|
|
462
|
+
if (containerDOM) {
|
|
463
|
+
const decrementDOM = decrementButton.element as HTMLElement
|
|
464
|
+
const labelDOM = label.element as HTMLElement
|
|
465
|
+
const incrementDOM = incrementButton.element as HTMLElement
|
|
466
|
+
|
|
467
|
+
if (decrementDOM) containerDOM.appendChild(decrementDOM)
|
|
468
|
+
if (labelDOM) containerDOM.appendChild(labelDOM)
|
|
469
|
+
if (incrementDOM) containerDOM.appendChild(incrementDOM)
|
|
470
|
+
|
|
471
|
+
// Add accessibility hint if provided
|
|
472
|
+
if (this.props.accessibilityHint) {
|
|
473
|
+
const hint = h('span', {
|
|
474
|
+
id: `${this.id}-hint`,
|
|
475
|
+
style: {
|
|
476
|
+
position: 'absolute',
|
|
477
|
+
width: '1px',
|
|
478
|
+
height: '1px',
|
|
479
|
+
padding: '0',
|
|
480
|
+
margin: '-1px',
|
|
481
|
+
overflow: 'hidden',
|
|
482
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
483
|
+
whiteSpace: 'nowrap',
|
|
484
|
+
border: '0',
|
|
485
|
+
},
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
const hintDOM = hint.element as HTMLElement
|
|
489
|
+
if (hintDOM) {
|
|
490
|
+
hintDOM.textContent = this.props.accessibilityHint
|
|
491
|
+
containerDOM.appendChild(hintDOM)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Set up reactive effects for value changes
|
|
497
|
+
createEffect(() => {
|
|
498
|
+
// Update button states when value changes
|
|
499
|
+
if (this.incrementButton) {
|
|
500
|
+
const canInc: boolean = this.canIncrement()
|
|
501
|
+
;(this.incrementButton as any).disabled = !canInc
|
|
502
|
+
this.incrementButton.style.backgroundColor = canInc
|
|
503
|
+
? this.theme.colors.buttonBackground
|
|
504
|
+
: this.theme.colors.buttonDisabled
|
|
505
|
+
this.incrementButton.style.color = canInc
|
|
506
|
+
? this.theme.colors.buttonText
|
|
507
|
+
: this.theme.colors.disabledText
|
|
508
|
+
this.incrementButton.style.cursor = canInc ? 'pointer' : 'not-allowed'
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (this.decrementButton) {
|
|
512
|
+
const canDec: boolean = this.canDecrement()
|
|
513
|
+
;(this.decrementButton as any).disabled = !canDec
|
|
514
|
+
this.decrementButton.style.backgroundColor = canDec
|
|
515
|
+
? this.theme.colors.buttonBackground
|
|
516
|
+
: this.theme.colors.buttonDisabled
|
|
517
|
+
this.decrementButton.style.color = canDec
|
|
518
|
+
? this.theme.colors.buttonText
|
|
519
|
+
: this.theme.colors.disabledText
|
|
520
|
+
this.decrementButton.style.cursor = canDec ? 'pointer' : 'not-allowed'
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Update label text
|
|
524
|
+
const labelElement = containerDOM?.querySelector(
|
|
525
|
+
'span:not([id$="-hint"])'
|
|
526
|
+
) as HTMLElement
|
|
527
|
+
if (labelElement) {
|
|
528
|
+
const currentValue = this.getValue()
|
|
529
|
+
const formattedValue = this.formatValue(currentValue)
|
|
530
|
+
|
|
531
|
+
let labelText = this.props.title || ''
|
|
532
|
+
if (this.props.displayValueInLabel !== false && this.props.title) {
|
|
533
|
+
labelText = `${this.props.title}: ${formattedValue}`
|
|
534
|
+
} else if (
|
|
535
|
+
!this.props.title &&
|
|
536
|
+
this.props.displayValueInLabel !== false
|
|
537
|
+
) {
|
|
538
|
+
labelText = formattedValue
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
labelElement.textContent = labelText
|
|
542
|
+
labelElement.style.color = this.isDisabled()
|
|
543
|
+
? this.theme.colors.disabledText
|
|
544
|
+
: this.theme.colors.text
|
|
545
|
+
}
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
return container
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Create a Stepper component
|
|
554
|
+
*/
|
|
555
|
+
export function Stepper(
|
|
556
|
+
props: StepperProps
|
|
557
|
+
): ModifiableComponent<StepperProps> & {
|
|
558
|
+
modifier: ModifierBuilder<ModifiableComponent<StepperProps>>
|
|
559
|
+
} {
|
|
560
|
+
return withModifiers(new StepperComponent(props))
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Stepper utility functions and presets
|
|
565
|
+
*/
|
|
566
|
+
export const StepperUtils = {
|
|
567
|
+
/**
|
|
568
|
+
* Create a quantity stepper (1-99, integer values)
|
|
569
|
+
*/
|
|
570
|
+
quantity(
|
|
571
|
+
value: Signal<number>,
|
|
572
|
+
onChange?: (value: number) => void
|
|
573
|
+
): Omit<StepperProps, 'value'> & { value: Signal<number> } {
|
|
574
|
+
return {
|
|
575
|
+
title: 'Quantity',
|
|
576
|
+
value,
|
|
577
|
+
minimumValue: 1,
|
|
578
|
+
maximumValue: 99,
|
|
579
|
+
step: 1,
|
|
580
|
+
onChange,
|
|
581
|
+
displayValueInLabel: true,
|
|
582
|
+
accessibilityLabel: 'Product quantity',
|
|
583
|
+
incrementAccessibilityLabel: 'Increase quantity',
|
|
584
|
+
decrementAccessibilityLabel: 'Decrease quantity',
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Create an age stepper (0-120, integer values)
|
|
590
|
+
*/
|
|
591
|
+
age(
|
|
592
|
+
value: Signal<number>,
|
|
593
|
+
onChange?: (value: number) => void
|
|
594
|
+
): Omit<StepperProps, 'value'> & { value: Signal<number> } {
|
|
595
|
+
return {
|
|
596
|
+
title: 'Age',
|
|
597
|
+
value,
|
|
598
|
+
minimumValue: 0,
|
|
599
|
+
maximumValue: 120,
|
|
600
|
+
step: 1,
|
|
601
|
+
onChange,
|
|
602
|
+
displayValueInLabel: true,
|
|
603
|
+
accessibilityLabel: 'Age in years',
|
|
604
|
+
incrementAccessibilityLabel: 'Increase age',
|
|
605
|
+
decrementAccessibilityLabel: 'Decrease age',
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Create a percentage stepper (0-100%, integer values)
|
|
611
|
+
*/
|
|
612
|
+
percentage(
|
|
613
|
+
value: Signal<number>,
|
|
614
|
+
onChange?: (value: number) => void
|
|
615
|
+
): Omit<StepperProps, 'value'> & { value: Signal<number> } {
|
|
616
|
+
return {
|
|
617
|
+
title: 'Percentage',
|
|
618
|
+
value,
|
|
619
|
+
minimumValue: 0,
|
|
620
|
+
maximumValue: 100,
|
|
621
|
+
step: 1,
|
|
622
|
+
onChange,
|
|
623
|
+
displayValueInLabel: true,
|
|
624
|
+
valueFormatter: (val: number) => `${val}%`,
|
|
625
|
+
accessibilityLabel: 'Percentage value',
|
|
626
|
+
incrementAccessibilityLabel: 'Increase percentage',
|
|
627
|
+
decrementAccessibilityLabel: 'Decrease percentage',
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Create a rating stepper (1-5 or 1-10, decimal values allowed)
|
|
633
|
+
*/
|
|
634
|
+
rating(
|
|
635
|
+
value: Signal<number>,
|
|
636
|
+
maxRating: number = 5,
|
|
637
|
+
step: number = 0.5,
|
|
638
|
+
onChange?: (value: number) => void
|
|
639
|
+
): Omit<StepperProps, 'value'> & { value: Signal<number> } {
|
|
640
|
+
return {
|
|
641
|
+
title: 'Rating',
|
|
642
|
+
value,
|
|
643
|
+
minimumValue: 0,
|
|
644
|
+
maximumValue: maxRating,
|
|
645
|
+
step,
|
|
646
|
+
onChange,
|
|
647
|
+
displayValueInLabel: true,
|
|
648
|
+
valueFormatter: (val: number) => `${val}/${maxRating}`,
|
|
649
|
+
accessibilityLabel: `Rating out of ${maxRating}`,
|
|
650
|
+
incrementAccessibilityLabel: 'Increase rating',
|
|
651
|
+
decrementAccessibilityLabel: 'Decrease rating',
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Create a price stepper (0+, decimal values with currency formatting)
|
|
657
|
+
*/
|
|
658
|
+
price(
|
|
659
|
+
value: Signal<number>,
|
|
660
|
+
currency: string = '$',
|
|
661
|
+
step: number = 0.01,
|
|
662
|
+
maxValue?: number,
|
|
663
|
+
onChange?: (value: number) => void
|
|
664
|
+
): Omit<StepperProps, 'value'> & { value: Signal<number> } {
|
|
665
|
+
return {
|
|
666
|
+
title: 'Price',
|
|
667
|
+
value,
|
|
668
|
+
minimumValue: 0,
|
|
669
|
+
maximumValue: maxValue,
|
|
670
|
+
step,
|
|
671
|
+
onChange,
|
|
672
|
+
displayValueInLabel: true,
|
|
673
|
+
valueFormatter: (val: number) => `${currency}${val.toFixed(2)}`,
|
|
674
|
+
accessibilityLabel: 'Price amount',
|
|
675
|
+
incrementAccessibilityLabel: 'Increase price',
|
|
676
|
+
decrementAccessibilityLabel: 'Decrease price',
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Create a font size stepper (8-72pt, integer values)
|
|
682
|
+
*/
|
|
683
|
+
fontSize(
|
|
684
|
+
value: Signal<number>,
|
|
685
|
+
onChange?: (value: number) => void
|
|
686
|
+
): Omit<StepperProps, 'value'> & { value: Signal<number> } {
|
|
687
|
+
return {
|
|
688
|
+
title: 'Font Size',
|
|
689
|
+
value,
|
|
690
|
+
minimumValue: 8,
|
|
691
|
+
maximumValue: 72,
|
|
692
|
+
step: 1,
|
|
693
|
+
onChange,
|
|
694
|
+
displayValueInLabel: true,
|
|
695
|
+
valueFormatter: (val: number) => `${val}pt`,
|
|
696
|
+
accessibilityLabel: 'Font size in points',
|
|
697
|
+
incrementAccessibilityLabel: 'Increase font size',
|
|
698
|
+
decrementAccessibilityLabel: 'Decrease font size',
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Stepper styles and theming
|
|
705
|
+
*/
|
|
706
|
+
export const StepperStyles = {
|
|
707
|
+
theme: defaultStepperTheme,
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Create a custom theme
|
|
711
|
+
*/
|
|
712
|
+
createTheme(overrides: Partial<StepperTheme>): StepperTheme {
|
|
713
|
+
return { ...defaultStepperTheme, ...overrides }
|
|
714
|
+
},
|
|
715
|
+
}
|