nitro-web 0.0.144 → 0.0.146

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,119 @@
1
+ import { isValid, format } from 'date-fns'
2
+ import { TZDate } from '@date-fns/tz'
3
+ import { twMerge } from 'nitro-web'
4
+ import { dayButtonClassName } from '../element/calendar'
5
+
6
+ type Timestamp = null | number
7
+ export type TimePickerProps = {
8
+ className?: string
9
+ onChange?: (value: Timestamp) => void
10
+ tz?: string
11
+ value?: Timestamp
12
+ }
13
+
14
+ export function TimePicker({ value, onChange, className, tz }: TimePickerProps) {
15
+ const refs = {
16
+ hour: useRef<HTMLDivElement>(null),
17
+ minute: useRef<HTMLDivElement>(null),
18
+ period: useRef<HTMLDivElement>(null),
19
+ }
20
+ const lists = [
21
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], // hours
22
+ [
23
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
24
+ 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50,
25
+ 51, 52, 53, 54, 55, 56, 57, 58, 59,
26
+ ], // minutes
27
+ ['AM', 'PM'], // AM/PM
28
+ ]
29
+
30
+ // Convert the value to an valid* date
31
+ const internalValue = useMemo(() => {
32
+ return value && isValid(value) ? new TZDate(value, tz) : undefined
33
+ }, [value])
34
+
35
+ // Get current values from date or use defaults
36
+ const hour = useMemo(() => internalValue ? parseInt(format(internalValue, 'h')) : undefined, [internalValue])
37
+ const minute = useMemo(() => internalValue ? parseInt(format(internalValue, 'm')) : undefined, [internalValue])
38
+ const period = useMemo(() => internalValue ? format(internalValue, 'a') : undefined, [internalValue])
39
+
40
+ // Scroll into view when the date changes
41
+ useEffect(() => {
42
+ if (hour !== undefined) scrollIntoView('hour', hour)
43
+ if (minute !== undefined) scrollIntoView('minute', minute)
44
+ if (period) scrollIntoView('period', period)
45
+ }, [hour, minute, period])
46
+
47
+ const handleTimeChange = (type: 'hour' | 'minute' | 'period', value: string | number) => {
48
+ // use original internValue or new TZDate, make sure to use the same timezone to base it from
49
+ const _internalValue = internalValue ?? new TZDate(new Date(), tz)
50
+ const isPm = format(_internalValue, 'a') === 'PM'
51
+ if (type === 'hour') {
52
+ const newHour = value === 12 ? 0 : value as number
53
+ _internalValue.setHours(newHour + (isPm ? 12 : 0))
54
+ } else if (type === 'minute') {
55
+ _internalValue.setMinutes(value as number)
56
+ } else if (type === 'period') {
57
+ const newHours = _internalValue.getHours() + (isPm && value === 'AM' ? -12 : !isPm && value === 'PM' ? 12 : 0)
58
+ _internalValue.setHours(newHours)
59
+ }
60
+
61
+ onChange?.(_internalValue.getTime())
62
+ }
63
+
64
+ function scrollIntoView (type: 'hour' | 'minute' | 'period', value: string | number) {
65
+ const container = refs[type].current
66
+ if (!container) return
67
+ const element = container.querySelector(`[data-val="${value}"]`) as HTMLElement
68
+ if (!element) return
69
+
70
+ const target =
71
+ element.offsetTop
72
+ - (container.clientHeight / 2)
73
+ + (element.clientHeight / 2)
74
+
75
+ container.scrollTo({ top: target, behavior: 'smooth' })
76
+ }
77
+
78
+ return (
79
+ <div className={twMerge('flex justify-center min-h-[250px]', className)}>
80
+ {
81
+ lists.map((list, i) => {
82
+ const type = i === 0 ? 'hour' : i === 1 ? 'minute' : 'period'
83
+ const currentValue = i === 0 ? hour : i === 1 ? minute : period
84
+ return (
85
+ <div
86
+ key={i}
87
+ ref={refs[type]}
88
+ className="w-[60px] relative overflow-hidden hover:overflow-y-auto border-l border-gray-100 sm-scrollbar first:border-l-0"
89
+ >
90
+ <div className="w-[60px] absolute flex flex-col items-center py-2">
91
+ {/* using absolute since the scrollbar takes up space */}
92
+ {list.map(item => (
93
+ <div
94
+ className="py-[1px] flex group cursor-pointer"
95
+ data-val={item}
96
+ key={item}
97
+ onClick={(_e) => {
98
+ handleTimeChange(type, item)
99
+ }}
100
+ >
101
+ <button
102
+ key={item}
103
+ className={
104
+ `${dayButtonClassName} rounded-full flex justify-center items-center group-hover:bg-gray-100 `
105
+ + (item === currentValue ? '!bg-input-border-focus text-white' : '')
106
+ }
107
+ >
108
+ {item.toString().padStart(2, '0').toLowerCase()}
109
+ </button>
110
+ </div>
111
+ ))}
112
+ </div>
113
+ </div>
114
+ )
115
+ })
116
+ }
117
+ </div>
118
+ )
119
+ }
@@ -1,31 +1,34 @@
1
1
  import { hsvaToHex, hexToHsva, validHex, HsvaColor } from '@uiw/color-convert'
2
2
  import Saturation from '@uiw/react-color-saturation'
3
3
  import Hue from '@uiw/react-color-hue'
4
- import { Dropdown, util } from 'nitro-web'
4
+ import { currency, Dropdown, util } from 'nitro-web'
5
5
 
6
- export type FieldColorProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'|'value'> & {
6
+ export type FieldColorProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue'> & {
7
7
  name: string
8
8
  /** name is applied if id is not provided */
9
9
  id?: string
10
- defaultColor?: string
11
10
  Icon?: React.ReactNode
12
11
  onChange?: (event: { target: { name: string, value: string } }) => void
13
- value?: string
12
+ value?: string // e.g. '#333'
13
+ defaultValue?: string // e.g. '#333'
14
14
  }
15
15
 
16
- export function FieldColor({ defaultColor='#333', Icon, onChange: onChangeProp, value: valueProp, ...props }: FieldColorProps) {
17
- const [lastChanged, setLastChanged] = useState(() => `ic-${Date.now()}`)
16
+ export function FieldColor({ defaultValue='#000', Icon, onChange: onChangeProp, ...props }: FieldColorProps) {
18
17
  const isInvalid = props.className?.includes('is-invalid') ? 'is-invalid' : ''
19
18
  const id = props.id || props.name
20
19
 
21
- // Since value and onChange are optional, we need to hold the value in state if not provided
22
- const [internalValue, setInternalValue] = useState(valueProp ?? defaultColor)
23
- const value = valueProp ?? internalValue
24
- const onChange = onChangeProp ?? ((e: { target: { name: string, value: string } }) => setInternalValue(e.target.value))
20
+ // Since value and onChange are optional, we need need to create an internal value state
21
+ const [internalValue, setInternalValue] = useState<FieldColorProps['value']>(props.value ?? defaultValue)
22
+ const inputValue = internalValue
25
23
 
26
- function onInputChange(e: { target: { name: string, value: string } }) {
27
- setLastChanged(`ic-${Date.now()}`)
28
- onChange(e)
24
+ // Update the internal value when the value changes outside of the component
25
+ useEffect(() => {
26
+ if (internalValue !== (props.value ?? defaultValue)) setInternalValue(props.value ?? defaultValue)
27
+ }, [props.value])
28
+
29
+ function onChange(e: { target: { name: string, value: string } }) {
30
+ if (onChangeProp) onChangeProp({ target: { name: props.name, value: e.target.value }})
31
+ else setInternalValue(e.target.value)
29
32
  }
30
33
 
31
34
  return (
@@ -33,7 +36,7 @@ export function FieldColor({ defaultColor='#333', Icon, onChange: onChangeProp,
33
36
  dir="bottom-left"
34
37
  menuToggles={false}
35
38
  menuContent={
36
- <ColorPicker key={lastChanged} defaultColor={defaultColor} name={props.name} value={value} onChange={onChange} />
39
+ <ColorPicker defaultValue={defaultValue} name={props.name} value={internalValue} onChange={onChange} />
37
40
  }
38
41
  >
39
42
  <div className="grid grid-cols-1">
@@ -42,9 +45,9 @@ export function FieldColor({ defaultColor='#333', Icon, onChange: onChangeProp,
42
45
  {...props}
43
46
  className={(props.className || '') + ' ' + isInvalid}
44
47
  id={id}
45
- value={value}
46
- onChange={onInputChange}
47
- onBlur={() => !validHex(value||'') && onInputChange({ target: { name: props.name, value: '' }})}
48
+ value={inputValue}
49
+ onChange={onChange}
50
+ onBlur={() => !validHex(internalValue||'') && onChange({ target: { name: props.name, value: '' }})} // wipe if invalid
48
51
  autoComplete="off"
49
52
  type="text"
50
53
  />
@@ -53,10 +56,15 @@ export function FieldColor({ defaultColor='#333', Icon, onChange: onChangeProp,
53
56
  )
54
57
  }
55
58
 
56
- function ColorPicker({ name='', onChange, value='', defaultColor='' }: FieldColorProps) {
57
- const [hsva, setHsva] = useState(() => hexToHsva(validHex(value) ? value : defaultColor))
59
+ function ColorPicker({ name='', onChange, value='', defaultValue='' }: FieldColorProps) {
60
+ const [hsva, setHsva] = useState(() => hexToHsva(validHex(value) ? value : defaultValue))
58
61
  const [debounce] = useState(() => util.throttle(callOnChange, 50))
59
62
 
63
+ // Update the hsva when the internal value changes
64
+ useEffect(() => {
65
+ if (validHex(value)) setHsva(hexToHsva(value))
66
+ }, [value])
67
+
60
68
  function callOnChange(newHsva: HsvaColor) {
61
69
  if (onChange) onChange({ target: { name: name, value: hsvaToHex(newHsva) }})
62
70
  }
@@ -15,128 +15,74 @@ type NumericFormatProps = React.InputHTMLAttributes<HTMLInputElement> & {
15
15
  prefix?: string;
16
16
  }
17
17
 
18
- export type FieldCurrencyProps = NumericFormatProps & {
18
+ type Currencies = { [key: string]: { symbol: string, digits: number } }
19
+ type Cents = string|number|null
20
+ export type FieldCurrencyProps = Omit<NumericFormatProps, 'onChange' | 'value' | 'defaultValue'> & {
19
21
  name: string
20
22
  /** name is applied if id is not provided */
21
23
  id?: string
22
24
  /** currency iso, e.g. 'nzd' */
23
25
  currency: string
24
26
  /** override the default currencies array used to lookup currency symbol and digits, e.g. {nzd: { symbol: '$', digits: 2 }} */
25
- currencies?: { [key: string]: { symbol: string, digits: number } },
27
+ currencies?: Currencies,
26
28
  /** override the default CLDR country currency format, e.g. '¤#,##0.00' */
27
29
  format?: string,
28
- onChange?: (event: { target: { name: string, value: string|number|null } }) => void
30
+ onChange?: (event: { target: { name: string, value: Cents } }) => void
29
31
  /** value should be in cents */
30
- value?: string|number|null
31
- defaultValue?: number | string | null
32
+ value?: Cents
33
+ defaultValue?: Cents // defined to just fix typescript error
32
34
  }
33
35
 
34
- export function FieldCurrency({ currency='nzd', currencies, format, onChange, value, defaultValue, ...props }: FieldCurrencyProps) {
36
+ export function FieldCurrency({ currency='nzd', currencies, format, onChange: onChangeProp, ...props }: FieldCurrencyProps) {
35
37
  const [dontFix, setDontFix] = useState(false)
36
- const [settings, setSettings] = useState(() => getCurrencySettings(currency))
37
- const [dollars, setDollars] = useState(() => toDollars(value, true, settings))
38
+ const [lastBlurred, setLastBlurred] = useState(0)
39
+ const [settings, setSettings] = useState(() => getCurrencySettings(currency, currencies, format, props.name))
38
40
  const [prefixWidth, setPrefixWidth] = useState(0)
39
- const ref = useRef({ settings, dontFix }) // was null
41
+ const ref = useRef({ dontFix }) // was null
40
42
  const id = props.id || props.name
41
- ref.current = { settings, dontFix }
43
+ ref.current = { dontFix }
42
44
 
45
+ // Since value and onChange are optional, we need need to create an internal value state
46
+ const [internalValue, setInternalValue] = useState<FieldCurrencyProps['value']>(props.value ?? props.defaultValue)
47
+ const inputValue = useMemo(() => to('dollars', internalValue, settings), [internalValue, settings.key, lastBlurred])
48
+
49
+ // Update the internal value when the value changes outside of the component
43
50
  useEffect(() => {
44
- if (settings.currency !== currency) {
45
- const settings = getCurrencySettings(currency)
46
- setSettings(settings)
47
- setDollars(toDollars(value, true, settings)) // required latest _settings
48
- }
49
- }, [currency])
51
+ if (internalValue !== (props.value ?? props.defaultValue)) setInternalValue(props.value ?? props.defaultValue)
52
+ }, [props.value])
50
53
 
54
+ // Update the settings if the setting parameters change
51
55
  useEffect(() => {
52
- if (ref.current.dontFix) {
53
- setDollars(toDollars(value))
54
- setDontFix(false)
55
- } else {
56
- setDollars(toDollars(value, true))
57
- }
58
- }, [value])
56
+ const _settings = getCurrencySettings(currency, currencies, format, props.name)
57
+ if (settings.key !== _settings.key) setSettings(_settings)
58
+ }, [currency, currencies, format])
59
59
 
60
+ // Get the prefix content width
60
61
  useEffect(() => {
61
- // Get the prefix content width
62
62
  setPrefixWidth(settings.prefix ? getPrefixWidth(settings.prefix, 1) : 0)
63
63
  }, [settings.prefix])
64
64
 
65
- function toCents(value?: string|number|null) {
66
- const maxDecimals = ref.current.settings.maxDecimals
65
+ function to(type: 'dollars' | 'cents', value?: Cents, settings?: { maxDecimals?: number }) {
66
+ const maxDecimals = settings?.maxDecimals
67
67
  const parsed = parseFloat(value + '')
68
68
  if (!parsed && parsed !== 0) return null
69
69
  if (!maxDecimals) return parsed
70
- const value2 = Math.round(parsed * Math.pow(10, maxDecimals)) // e.g. 1.23 => 123
71
- // console.log('toCents', parsed, value2)
72
- return value2
73
- }
74
70
 
75
- function toDollars(value?: string|number|null, toFixed?: boolean, settings?: { maxDecimals?: number }) {
76
- const maxDecimals = (settings || ref.current.settings).maxDecimals
77
- const parsed = parseFloat(value + '')
78
- if (!parsed && parsed !== 0) return null
79
- if (!maxDecimals) return parsed
80
- const value2 = parsed / Math.pow(10, maxDecimals) // e.g. 1.23 => 123
81
- // console.log('toDollars', value, value2)
82
- return toFixed ? value2.toFixed(maxDecimals) : value2
83
- }
71
+ const value2 = type === 'dollars'
72
+ ? parsed / Math.pow(10, maxDecimals)
73
+ : parsed * Math.pow(10, maxDecimals) // e.g. 1.23 => 123
84
74
 
85
- function getCurrencySettings(currency: string) {
86
- // parse CLDR currency string format, e.g. '¤#,##0.00'
87
- const output: {
88
- currency: string, // e.g. 'nzd'
89
- decimalSeparator?: string, // e.g. '.'
90
- thousandSeparator?: string, // e.g. ','
91
- minDecimals?: number, // e.g. 2
92
- maxDecimals?: number, // e.g. 2
93
- prefix?: string, // e.g. '$'
94
- suffix?: string // e.g. ''
95
- } = { currency }
96
-
97
- let _format = format || defaultFormat
98
- const _currencies = currencies ?? defaultCurrencies
99
- const currencyObject = _currencies[currency as keyof typeof _currencies]
100
- if (!currencyObject && currencies) {
101
- console.error(
102
- `The currency field "${props.name}" is using the currency "${currency}" which is not found in your currencies object`
103
- )
104
- } else if (!currencyObject && !currencies) {
105
- console.error(
106
- `The currency field "${props.name}" is using the currency "${currency}" which is not found in the
107
- default currencies, please provide a currencies object.`
108
- )
109
- }
110
- const symbol = currencyObject ? currencyObject.symbol : ''
111
- const digits = currencyObject ? currencyObject.digits : 2
112
-
113
- // Check for currency symbol (¤) and determine its position
114
- if (_format.indexOf('¤') !== -1) {
115
- const position = _format.indexOf('¤') === 0 ? 'prefix' : 'suffix'
116
- output[position] = symbol
117
- _format = _format.replace('¤', '')
118
- }
75
+ // dont fix when the user is typing.
76
+ if (type === 'dollars' && ref.current.dontFix) { setDontFix(false) }
119
77
 
120
- // Find and set the thousands separator
121
- const thousandMatch = _format.match(/[^0-9#]/)
122
- if (thousandMatch) output.thousandSeparator = thousandMatch[0]
123
-
124
- // Find and set the decimal separator and fraction digits
125
- const decimalMatch = _format.match(/0[^0-9]/)
126
- if (decimalMatch) {
127
- output.decimalSeparator = decimalMatch[0].slice(1)
128
- if (typeof digits !== 'undefined') {
129
- output.minDecimals = digits
130
- output.maxDecimals = digits
131
- } else {
132
- const fractionDigits = _format.split(output.decimalSeparator)[1]
133
- if (fractionDigits) {
134
- output.minDecimals = fractionDigits.length
135
- output.maxDecimals = fractionDigits.length
136
- }
137
- }
138
- }
139
- return output
78
+ // console.log('to', type, value, value2)
79
+ return type === 'cents' || ref.current.dontFix ? value2 : value2.toFixed(maxDecimals)
80
+ }
81
+
82
+ function onChange(source: 'event' | 'prop', floatValue?: number) {
83
+ if (source === 'event') setDontFix(true)
84
+ if (onChangeProp) onChangeProp({ target: { name: props.name, value: to('cents', floatValue, settings) }})
85
+ else setInternalValue(to('cents', floatValue, settings))
140
86
  }
141
87
 
142
88
  return (
@@ -148,20 +94,15 @@ export function FieldCurrency({ currency='nzd', currencies, format, onChange, va
148
94
  decimalSeparator={settings.decimalSeparator}
149
95
  thousandSeparator={settings.thousandSeparator}
150
96
  decimalScale={settings.maxDecimals}
151
- onValueChange={!onChange ? undefined : ({ floatValue }, e) => {
152
- // console.log('onValueChange', floatValue, e)
153
- if (e.source === 'event') setDontFix(true)
154
- onChange({ target: { name: props.name, value: toCents(floatValue) }})
155
- }}
156
- onBlur={() => { setDollars(toDollars(value, true))}}
97
+ onValueChange={({ floatValue }, e) => onChange(e.source, floatValue)}
98
+ onBlur={() => { setLastBlurred(Date.now())}}
157
99
  placeholder={props.placeholder || '0.00'}
158
- value={dollars}
100
+ value={inputValue}
159
101
  style={{ textIndent: `${prefixWidth}px` }}
160
102
  type="text"
161
- defaultValue={defaultValue}
162
103
  />
163
104
  <span
164
- class={`absolute top-0 bottom-0 left-[12px] left-input-x inline-flex items-center select-none text-gray-500 text-input-base ${dollars !== null && settings.prefix == '$' ? 'text-foreground' : ''}`}
105
+ class={`absolute top-0 bottom-0 left-[12px] left-input-x inline-flex items-center select-none text-gray-500 text-input-base ${inputValue !== null && settings.prefix == '$' ? 'text-foreground' : ''}`}
165
106
  >
166
107
  {settings.prefix || settings.suffix}
167
108
  </span>
@@ -169,6 +110,71 @@ export function FieldCurrency({ currency='nzd', currencies, format, onChange, va
169
110
  )
170
111
  }
171
112
 
113
+ function getCurrencySettings(currency: string, currencies?: Currencies, format?: string, name?: string) {
114
+ // parse CLDR currency string format, e.g. '¤#,##0.00'
115
+ const output: {
116
+ key?: string
117
+ currency: string, // e.g. 'nzd'
118
+ decimalSeparator?: string, // e.g. '.'
119
+ thousandSeparator?: string, // e.g. ','
120
+ minDecimals?: number, // e.g. 2
121
+ maxDecimals?: number, // e.g. 2
122
+ prefix?: string, // e.g. '$'
123
+ suffix?: string // e.g. ''
124
+ } = { currency }
125
+
126
+ let _format = format || defaultFormat
127
+ const _currencies = currencies ?? defaultCurrencies
128
+ const currencyObject = _currencies[currency as keyof typeof _currencies]
129
+ if (!currencyObject && currencies) {
130
+ console.error(
131
+ `The currency field "${name||''}" is using the currency "${currency}" which is not found in your currencies object`
132
+ )
133
+ } else if (!currencyObject && !currencies) {
134
+ console.error(
135
+ `The currency field "${name||''}" is using the currency "${currency}" which is not found in the
136
+ default currencies, please provide a currencies object.`
137
+ )
138
+ }
139
+ const symbol = currencyObject ? currencyObject.symbol : ''
140
+ const digits = currencyObject ? currencyObject.digits : 2
141
+
142
+ // Check for currency symbol (¤) and determine its position
143
+ if (_format.indexOf('¤') !== -1) {
144
+ const position = _format.indexOf('¤') === 0 ? 'prefix' : 'suffix'
145
+ output[position] = symbol
146
+ _format = _format.replace('¤', '')
147
+ }
148
+
149
+ // Find and set the thousands separator
150
+ const thousandMatch = _format.match(/[^0-9#]/)
151
+ if (thousandMatch) output.thousandSeparator = thousandMatch[0]
152
+
153
+ // Find and set the decimal separator and fraction digits
154
+ const decimalMatch = _format.match(/0[^0-9]/)
155
+ if (decimalMatch) {
156
+ output.decimalSeparator = decimalMatch[0].slice(1)
157
+ if (typeof digits !== 'undefined') {
158
+ output.minDecimals = digits
159
+ output.maxDecimals = digits
160
+ } else {
161
+ const fractionDigits = _format.split(output.decimalSeparator)[1]
162
+ if (fractionDigits) {
163
+ output.minDecimals = fractionDigits.length
164
+ output.maxDecimals = fractionDigits.length
165
+ }
166
+ }
167
+ }
168
+
169
+ // create stable key from final output
170
+ output.key = Object.keys(output)
171
+ .filter(k => k !== 'key')
172
+ .sort()
173
+ .map(k => `${k}:${output[k as keyof typeof output] ?? ''}`)
174
+ .join('|')
175
+ return output
176
+ }
177
+
172
178
  export const defaultCurrencies: { [key: string]: { name: string, symbol: string, digits: number } } = {
173
179
  nzd: { name: 'New Zealand Dollar', symbol: '$', digits: 2 },
174
180
  aud: { name: 'Australian Dollar', symbol: '$', digits: 2 },