@startupjs-ui/date-time-picker 0.1.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
6
+ ## [0.1.3](https://github.com/startupjs/startupjs-ui/compare/v0.1.2...v0.1.3) (2025-12-29)
7
+
8
+ **Note:** Version bump only for package @startupjs-ui/date-time-picker
9
+
10
+
11
+
12
+
13
+
14
+ ## [0.1.2](https://github.com/startupjs/startupjs-ui/compare/v0.1.1...v0.1.2) (2025-12-29)
15
+
16
+
17
+ ### Features
18
+
19
+ * add mdx and docs packages. Refactor docs to get rid of any @startupjs/ui usage and use startupjs-ui instead ([703c926](https://github.com/startupjs/startupjs-ui/commit/703c92636efb0421ffd11783f692fc892b74018f))
20
+ * **date-time-picker:** refactor DateTimePicker component. TODO: fix moment.tz.guess() by loading timezone data correctly. ([ae39c14](https://github.com/startupjs/startupjs-ui/commit/ae39c1450ce6d57e6323c4777318de1e57667041))
package/README.mdx ADDED
@@ -0,0 +1,86 @@
1
+ import { useState } from 'react'
2
+ import { $ } from 'startupjs'
3
+ import DateTimePicker, { _PropsJsonSchema as DateTimePickerPropsJsonSchema } from './index'
4
+ import { Sandbox } from '@startupjs-ui/docs'
5
+
6
+ # DateTimePicker
7
+
8
+ DateTimePicker allows to display and change the date and time.
9
+
10
+ ```jsx
11
+ import { DateTimePicker } from 'startupjs-ui'
12
+ ```
13
+
14
+ ## Simple example
15
+
16
+ ```jsx example
17
+ const [date, setDate] = useState(+new Date())
18
+
19
+ return (
20
+ <DateTimePicker
21
+ date={date}
22
+ onChangeDate={setDate}
23
+ />
24
+ )
25
+ ```
26
+
27
+ ## Managing visibility
28
+
29
+ There are three options for managing visiblity of a `DateTimePicker`:
30
+
31
+ 1. By passing the scoped model to the `$visible` property from the state of which visibility is controlled.
32
+
33
+ ```jsx example
34
+ const $visible = $()
35
+ const [date, setDate] = useState()
36
+
37
+ return (
38
+ <DateTimePicker
39
+ date={date}
40
+ $visible={$visible}
41
+ onChangeDate={setDate}
42
+ />
43
+ )
44
+ ```
45
+
46
+ 2. By passing the `visible`, `onRequestOpen` and `onRequestClose` properties that determines whether `DateTimePicker` is visible.
47
+
48
+ ```jsx example
49
+ const $visible = $()
50
+ const [date, setDate] = useState()
51
+ const visible = $visible.get()
52
+
53
+ return (
54
+ <DateTimePicker
55
+ date={date}
56
+ visible={visible}
57
+ onChangeDate={setDate}
58
+ onRequestOpen={() => $visible.set(true)}
59
+ onRequestClose={() => $visible.del()}
60
+ />
61
+ )
62
+ ```
63
+
64
+ 3. Uncontrolled.
65
+
66
+ ```jsx example
67
+ const [date, setDate] = useState()
68
+
69
+ return (
70
+ <DateTimePicker
71
+ date={date}
72
+ onChangeDate={setDate}
73
+ />
74
+ )
75
+ ```
76
+
77
+ ## Sandbox
78
+
79
+ <Sandbox
80
+ Component={DateTimePicker}
81
+ propsJsonSchema={DateTimePickerPropsJsonSchema}
82
+ props={{
83
+ date: +new Date()
84
+ }}
85
+ block
86
+ />
@@ -0,0 +1,47 @@
1
+ .shortName
2
+ color var(--color-text-description)
3
+ font(caption)
4
+
5
+ .label
6
+ font(caption)
7
+
8
+ &.isMute
9
+ color var(--color-text-description)
10
+
11
+ &.isActive
12
+ color var(--color-text-on-primary)
13
+
14
+ .cell
15
+ justify-content center
16
+ align-items center
17
+ width 4u
18
+ height 4u
19
+ margin .5u
20
+ radius(circle)
21
+ &:part(hover)
22
+ background-color var(--color-bg-primary-transparent)
23
+
24
+ &.isActive
25
+ background-color var(--color-bg-primary)
26
+ &:part(hover)
27
+ background-color var(--color-bg-primary-subtle)
28
+
29
+ &.isActiveRangeStart
30
+ border-top-right-radius 0
31
+ border-bottom-right-radius 0
32
+ background-color var(--color-bg-primary)
33
+
34
+ &.isActiveRangeEnd
35
+ border-top-left-radius 0
36
+ border-bottom-left-radius 0
37
+ background-color var(--color-bg-primary)
38
+
39
+ &.isActiveRange
40
+ border-radius 0
41
+ background-color var(--color-bg-primary-subtle)
42
+
43
+ .row
44
+ justify-content center
45
+
46
+ +from($UI.media.tablet)
47
+ justify-content flex-start
@@ -0,0 +1,133 @@
1
+ import { useMemo, useCallback, type ReactNode } from 'react'
2
+ import { pug, observer } from 'startupjs'
3
+ import Span from '@startupjs-ui/span'
4
+ import Div from '@startupjs-ui/div'
5
+ import moment from 'moment-timezone'
6
+ import './index.cssx.styl'
7
+
8
+ interface DaysProps {
9
+ date: Date
10
+ uiDate: number
11
+ exactLocale: string
12
+ timezone: string
13
+ disabledDays: number[]
14
+ maxDate?: number
15
+ minDate?: number
16
+ range?: [number, number]
17
+ onChangeDate?: (value: number) => void
18
+ }
19
+
20
+ function Days ({
21
+ date,
22
+ uiDate,
23
+ exactLocale,
24
+ timezone,
25
+ disabledDays,
26
+ maxDate,
27
+ minDate,
28
+ range,
29
+ onChangeDate
30
+ }: DaysProps): ReactNode {
31
+ const weekdaysShort = useMemo(() => {
32
+ const data = (moment
33
+ .tz(uiDate, timezone)
34
+ .locale(exactLocale) as any)
35
+ ._locale
36
+ ._weekdaysShort
37
+
38
+ return data.map((day: string) => day.toUpperCase())
39
+ }, [uiDate, timezone, exactLocale])
40
+
41
+ const matrixMonthDays = useMemo(() => {
42
+ const data = []
43
+
44
+ const nowDate = moment.tz(timezone)
45
+
46
+ const currentDay = moment
47
+ .tz(uiDate, timezone)
48
+ .startOf('M')
49
+ .startOf('w')
50
+ .hours(nowDate.hours())
51
+ .minutes(nowDate.minutes())
52
+ .seconds(nowDate.seconds())
53
+ .milliseconds(nowDate.milliseconds())
54
+
55
+ for (let weekIndex = 0; weekIndex < 6; weekIndex++) {
56
+ const weekLine = []
57
+ for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
58
+ weekLine.push({
59
+ label: currentDay.format('DD'),
60
+ month: currentDay.month(),
61
+ day: currentDay.date(),
62
+ value: +currentDay,
63
+ testID: `${currentDay.format('MM')}-` +
64
+ `${currentDay.format('DD')}-${currentDay.format('YYYY')}`
65
+ })
66
+ currentDay.add(1, 'd')
67
+ }
68
+ data.push(weekLine)
69
+ }
70
+
71
+ return data
72
+ }, [uiDate, timezone])
73
+
74
+ function _onChangeDay (item: any) {
75
+ const timestamp = +moment
76
+ .tz(uiDate, timezone)
77
+ .date(item.day)
78
+ .month(item.month)
79
+
80
+ onChangeDate && onChangeDate(timestamp)
81
+ }
82
+
83
+ const isDisableDay = useCallback((value: number) => {
84
+ const isDisabledDay = disabledDays.some(item => moment.tz(item, timezone).isSame(value, 'd'))
85
+ const isBeforeMinDate = minDate != null
86
+ ? moment.tz(minDate, timezone).isAfter(value, 'd')
87
+ : false
88
+ const isAfterMaxDate = maxDate != null
89
+ ? moment.tz(maxDate, timezone).isBefore(value, 'd')
90
+ : false
91
+
92
+ return isDisabledDay || isBeforeMinDate || isAfterMaxDate
93
+ }, [disabledDays, maxDate, minDate, timezone])
94
+
95
+ function getLabelActive (value: number) {
96
+ return range
97
+ ? moment.tz(value, timezone).isSame(range[0], 'd') ||
98
+ moment.tz(value, timezone).isSame(range[1], 'd')
99
+ : moment.tz(value, timezone).isSame(date, 'd')
100
+ }
101
+
102
+ return pug`
103
+ Div.row(row)
104
+ for shortDayName in weekdaysShort
105
+ Div.cell(key=shortDayName)
106
+ Span.shortName(bold)= shortDayName
107
+
108
+ for week, weekIndex in matrixMonthDays
109
+ Div.row(key='week-' + weekIndex row)
110
+ for day, dayIndex in matrixMonthDays[weekIndex]
111
+ Div.cell(
112
+ key=weekIndex + '-' + dayIndex
113
+ styleName={
114
+ isActive: !range && moment.tz(day.value, timezone).isSame(date, 'd'),
115
+ isActiveRangeStart: range && moment.tz(day.value, timezone).isSame(range[0], 'd'),
116
+ isActiveRange: range && moment.tz(day.value, timezone).isBetween(range[0], range[1], 'd'),
117
+ isActiveRangeEnd: range && moment.tz(day.value, timezone).isSame(range[1], 'd')
118
+ }
119
+ disabled=isDisableDay(day.value)
120
+ testID=day.testID
121
+ onPress=() => _onChangeDay(day)
122
+ )
123
+ Span.label(
124
+ bold=getLabelActive(day.value)
125
+ styleName={
126
+ isMute: !moment.tz(day.value, timezone).isSame(uiDate, 'M'),
127
+ isActive: getLabelActive(day.value)
128
+ }
129
+ )= day.label
130
+ `
131
+ }
132
+
133
+ export default observer(Days)
@@ -0,0 +1,40 @@
1
+ .header
2
+ align-items center
3
+ justify-content center
4
+ margin 0 0 .5u 1u
5
+
6
+ +from($UI.media.tablet)
7
+ justify-content space-between
8
+
9
+ .month
10
+ font(h5)
11
+
12
+ .yearText
13
+ color var(--color-text-primary)
14
+ font(h5)
15
+
16
+ .actions
17
+ justify-content flex-end
18
+ margin-left 2u
19
+
20
+ +from($UI.media.tablet)
21
+ margin-left 0
22
+
23
+ .button, .years
24
+ margin-left .5u
25
+
26
+ .years
27
+ &-popover
28
+ padding 0
29
+ max-height 27u
30
+
31
+ &-item
32
+ height 4.5u
33
+ align-items center
34
+ justify-content center
35
+ padding-right 2u
36
+ padding-left @padding-right
37
+
38
+ .year
39
+ font(h5)
40
+
@@ -0,0 +1,159 @@
1
+ import { useCallback, useMemo, type ReactNode } from 'react'
2
+ import { pug, observer, $ } from 'startupjs'
3
+ import Button from '@startupjs-ui/button'
4
+ import Div from '@startupjs-ui/div'
5
+ import FlatList from '@startupjs-ui/flat-list'
6
+ import Icon from '@startupjs-ui/icon'
7
+ import Popover from '@startupjs-ui/popover'
8
+ import Span from '@startupjs-ui/span'
9
+ import { faAngleLeft } from '@fortawesome/free-solid-svg-icons/faAngleLeft'
10
+ import { faAngleRight } from '@fortawesome/free-solid-svg-icons/faAngleRight'
11
+ import { faCaretDown } from '@fortawesome/free-solid-svg-icons/faCaretDown'
12
+ import moment from 'moment-timezone'
13
+ import STYLES from './index.cssx.styl'
14
+
15
+ const yearsItemStyle = STYLES['years-item']
16
+ const YEAR_ITEM_HEIGHT = yearsItemStyle.height
17
+
18
+ interface HeaderProps {
19
+ uiDate: number
20
+ exactLocale: string
21
+ timezone: string
22
+ minDate?: number
23
+ maxDate?: number
24
+ $uiDate: any
25
+ }
26
+
27
+ function Header ({
28
+ uiDate,
29
+ exactLocale,
30
+ timezone,
31
+ minDate,
32
+ maxDate,
33
+ $uiDate
34
+ }: HeaderProps): ReactNode {
35
+ const monthName = moment.tz(uiDate, timezone).locale(exactLocale).format('MMM')
36
+
37
+ const onChangeMonth = useCallback((value: number) => {
38
+ const ts = +moment($uiDate.get()).add('month', value)
39
+ $uiDate.set(ts)
40
+ }, [$uiDate])
41
+
42
+ const isPrevDisabled = minDate
43
+ ? +moment.tz($uiDate.get(), timezone).endOf('month').add('month', -1) < minDate
44
+ : false
45
+
46
+ const isNextDisabled = maxDate
47
+ ? +moment($uiDate.get()).startOf('month').add('month', 1) > maxDate
48
+ : false
49
+
50
+ return pug`
51
+ Div.header(row)
52
+ Div(vAlign='center' row)
53
+ Span.month(bold)= monthName
54
+ Years.years(
55
+ timezone=timezone
56
+ minDate=minDate
57
+ maxDate=maxDate
58
+ $uiDate=$uiDate
59
+ )
60
+ Div.actions(row)
61
+ Button.button(
62
+ color='text-description'
63
+ variant='text'
64
+ disabled=isPrevDisabled
65
+ icon=faAngleLeft
66
+ onPress=()=> onChangeMonth(-1)
67
+ )
68
+ Button.button(
69
+ color='text-description'
70
+ variant='text'
71
+ disabled=isNextDisabled
72
+ icon=faAngleRight
73
+ onPress=()=> onChangeMonth(1)
74
+ )
75
+ `
76
+ }
77
+
78
+ export default observer(Header)
79
+
80
+ interface YearsProps {
81
+ style?: any
82
+ minDate?: number
83
+ maxDate?: number
84
+ timezone: string
85
+ $uiDate: any
86
+ }
87
+
88
+ const Years = observer(function YearsComponent ({
89
+ style,
90
+ minDate,
91
+ maxDate,
92
+ timezone,
93
+ $uiDate
94
+ }: YearsProps): ReactNode {
95
+ const $visible = $(false)
96
+ const minYear = minDate ? moment.tz(minDate, timezone).year() : 1950
97
+ const maxYear = maxDate ? moment.tz(maxDate, timezone).year() : 2050
98
+ const yearsDiff = maxYear - minYear
99
+
100
+ const onChangeYear = useCallback((year: number) => {
101
+ const ts = +moment($uiDate.get()).year(year)
102
+ $uiDate.set(ts)
103
+ $visible.set(false)
104
+ }, [$uiDate, $visible])
105
+
106
+ const years = useMemo(() => {
107
+ return new Array(yearsDiff + 1).fill(minYear).map((year, index) => {
108
+ return year + index
109
+ })
110
+ }, [yearsDiff, minYear])
111
+
112
+ const getItemLayout = useCallback((data: any, index: number) => {
113
+ return {
114
+ offset: YEAR_ITEM_HEIGHT * index,
115
+ length: YEAR_ITEM_HEIGHT,
116
+ index
117
+ }
118
+ }, [])
119
+
120
+ if (!yearsDiff) {
121
+ return pug`
122
+ Div(style=style)
123
+ Span.year(bold)= maxYear
124
+ `
125
+ }
126
+
127
+ function renderYears (): ReactNode {
128
+ return pug`
129
+ FlatList(
130
+ data=years
131
+ renderItem=renderYear
132
+ keyExtractor=item => item
133
+ getItemLayout=getItemLayout
134
+ )
135
+ `
136
+ }
137
+
138
+ function renderYear ({ item }: { item: number }): ReactNode {
139
+ return pug`
140
+ Div.years-item(
141
+ variant='higlight'
142
+ onPress=() => onChangeYear(item)
143
+ )
144
+ Span= item
145
+ `
146
+ }
147
+
148
+ return pug`
149
+ Div(style=style)
150
+ Popover(
151
+ $visible=$visible
152
+ renderContent=renderYears
153
+ attachmentStyleName='years-popover'
154
+ )
155
+ Div(vAlign='center' row)
156
+ Span.year(bold)= moment.tz($uiDate.get(), timezone).year()
157
+ Icon(icon=faCaretDown)
158
+ `
159
+ })
@@ -0,0 +1,57 @@
1
+ import { type ReactNode } from 'react'
2
+ import { pug, observer, $ } from 'startupjs'
3
+ import Div from '@startupjs-ui/div'
4
+ import moment from 'moment-timezone'
5
+ import Header from './Header'
6
+ import Days from './Days'
7
+
8
+ interface CalendarProps {
9
+ date: Date
10
+ disabledDays?: number[]
11
+ exactLocale: string
12
+ timezone: string
13
+ maxDate?: number
14
+ minDate?: number
15
+ range?: [number, number]
16
+ testID?: string
17
+ onChangeDate?: (value: number) => void
18
+ }
19
+
20
+ function Calendar ({
21
+ date,
22
+ disabledDays = [],
23
+ exactLocale,
24
+ timezone,
25
+ maxDate,
26
+ minDate,
27
+ range,
28
+ testID,
29
+ onChangeDate
30
+ }: CalendarProps): ReactNode {
31
+ const $uiDate = $(+moment(date).seconds(0).milliseconds(0))
32
+
33
+ return pug`
34
+ Div(testID=testID)
35
+ Header(
36
+ uiDate=$uiDate.get()
37
+ exactLocale=exactLocale
38
+ timezone=timezone
39
+ minDate=minDate
40
+ maxDate=maxDate
41
+ $uiDate=$uiDate
42
+ )
43
+ Days(
44
+ date=date
45
+ uiDate=$uiDate.get()
46
+ exactLocale=exactLocale
47
+ timezone=timezone
48
+ range=range
49
+ minDate=minDate
50
+ maxDate=maxDate
51
+ disabledDays=disabledDays
52
+ onChangeDate=onChangeDate
53
+ )
54
+ `
55
+ }
56
+
57
+ export default observer(Calendar)
@@ -0,0 +1,23 @@
1
+ .cell
2
+ height 5u
3
+ width 20u
4
+ align-items center
5
+ justify-content center
6
+ align-self center
7
+ &:part(hover)
8
+ background-color var(--color-bg-primary-transparent)
9
+
10
+ +from($UI.media.tablet)
11
+ width 12u
12
+
13
+ &.isActive
14
+ background-color var(--color-bg-primary)
15
+ &:part(hover)
16
+ background-color var(--color-bg-primary-subtle)
17
+
18
+ .label
19
+ &.isActive
20
+ color var(--color-text-on-primary)
21
+
22
+ :export
23
+ media: $UI.media
@@ -0,0 +1,122 @@
1
+ import {
2
+ useEffect,
3
+ useMemo,
4
+ useRef,
5
+ useCallback,
6
+ useImperativeHandle,
7
+ type ReactNode,
8
+ type RefObject
9
+ } from 'react'
10
+ import { pug, observer } from 'startupjs'
11
+ import Div from '@startupjs-ui/div'
12
+ import FlatList from '@startupjs-ui/flat-list'
13
+ import Span from '@startupjs-ui/span'
14
+ import moment from 'moment-timezone'
15
+ import STYLES from './index.cssx.styl'
16
+
17
+ export interface TimeSelectRef {
18
+ scrollToIndex: (date?: Date) => void
19
+ }
20
+
21
+ interface TimeSelectProps {
22
+ date: Date
23
+ exactLocale: string
24
+ timezone: string
25
+ is24Hour?: boolean
26
+ minDate?: number
27
+ maxDate?: number
28
+ timeInterval: number
29
+ onChangeDate?: (value: number) => void
30
+ ref?: RefObject<TimeSelectRef>
31
+ }
32
+
33
+ // TODO: add displayTimeVariant
34
+ function TimeSelect ({
35
+ date,
36
+ exactLocale,
37
+ timezone,
38
+ is24Hour,
39
+ minDate,
40
+ maxDate,
41
+ timeInterval,
42
+ onChangeDate,
43
+ ref
44
+ }: TimeSelectProps): ReactNode {
45
+ const refScroll = useRef<any>(null)
46
+
47
+ // we are looking for 'a' in current locale
48
+ // to figure out whether to apply 12 hour format
49
+ const _is24Hour = useMemo(() => {
50
+ if (is24Hour != null) return is24Hour
51
+ const lt = (moment().locale(exactLocale) as any)._locale._longDateFormat.LT
52
+ return !/a/i.test(lt)
53
+ }, [is24Hour, exactLocale])
54
+
55
+ const preparedData = useMemo(() => {
56
+ const res: Array<{ label: string, value: number, disabled: boolean }> = []
57
+
58
+ let currentTimestamp = +moment.tz(date, timezone).locale(exactLocale).startOf('d')
59
+ const endTimestamp = +moment.tz(date, timezone).locale(exactLocale).endOf('d')
60
+ const intervalTimestamp = timeInterval * 60 * 1000
61
+
62
+ const format = _is24Hour
63
+ ? (moment.tz(date, timezone).locale(exactLocale) as any)._locale._longDateFormat.LT
64
+ : 'hh:mm A'
65
+
66
+ while (currentTimestamp < endTimestamp) {
67
+ res.push({
68
+ label: moment.tz(currentTimestamp, timezone).locale(exactLocale).format(format),
69
+ value: currentTimestamp,
70
+ disabled: (maxDate != null && currentTimestamp > maxDate) ||
71
+ (minDate != null && currentTimestamp < minDate)
72
+ })
73
+ currentTimestamp += intervalTimestamp
74
+ }
75
+
76
+ return res
77
+ }, [date, exactLocale, timezone, timeInterval, _is24Hour, maxDate, minDate])
78
+
79
+ const scrollToIndex = useCallback((_date: Date = date) => {
80
+ const dateTimestamp = +moment.tz(_date, timezone)
81
+ const index = preparedData.findIndex(item => dateTimestamp === item.value)
82
+ if (index === -1) return
83
+ refScroll.current?.scrollToIndex?.({ index, animated: false })
84
+ }, [date, timezone, preparedData])
85
+
86
+ useImperativeHandle(ref, () => ({ scrollToIndex }), [scrollToIndex])
87
+ useEffect(() => {
88
+ scrollToIndex()
89
+ }, [scrollToIndex])
90
+
91
+ function renderItem ({ item }: { item: any }): ReactNode {
92
+ const isActive = +moment(date) === item.value
93
+
94
+ return pug`
95
+ Div.cell(
96
+ styleName={ isActive }
97
+ disabled=item.disabled
98
+ onPress=()=> onChangeDate && onChangeDate(item.value)
99
+ )
100
+ Span.label(styleName={ isActive })
101
+ = item.label
102
+ `
103
+ }
104
+
105
+ const length = STYLES.cell.height
106
+
107
+ return pug`
108
+ FlatList(
109
+ ref=refScroll
110
+ data=preparedData
111
+ renderItem=renderItem
112
+ getItemLayout=(data, index) => ({
113
+ offset: length * index,
114
+ length,
115
+ index
116
+ })
117
+ keyExtractor=item=> String(item.value)
118
+ )
119
+ `
120
+ }
121
+
122
+ export default observer(TimeSelect)
@@ -0,0 +1,2 @@
1
+ export { default as Calendar } from './Calendar'
2
+ export { default as TimeSelect } from './TimeSelect'
@@ -0,0 +1,15 @@
1
+ import { I18nManager, Platform, Settings } from 'react-native'
2
+
3
+ export default function getLocale (): string {
4
+ if (Platform.OS === 'web') {
5
+ return typeof window !== 'undefined' ? window.navigator.language : 'en'
6
+ }
7
+
8
+ if (Platform.OS === 'ios') {
9
+ const appleLocale = Settings.get('AppleLocale') as string | undefined
10
+ const appleLanguages = Settings.get('AppleLanguages') as string[] | undefined
11
+ return appleLocale ?? appleLanguages?.[0] ?? 'en'
12
+ }
13
+
14
+ return (I18nManager.getConstants() as any).localeIdentifier
15
+ }
@@ -0,0 +1 @@
1
+ export { default as getLocale } from './getLocale'
@@ -0,0 +1,53 @@
1
+ .content
2
+ flex-direction column
3
+ padding 1u
4
+ flex-shrink 1
5
+
6
+ +from($UI.media.tablet)
7
+ flex-direction row
8
+ height 42u
9
+
10
+ .divider
11
+ align-self center
12
+ width 60%
13
+ height 1px
14
+ margin 1u 0
15
+
16
+ +from($UI.media.tablet)
17
+ height 95%
18
+ width 1px
19
+ margin 0 2u
20
+
21
+ .drawer
22
+ height 95%
23
+ border-radius 0
24
+ padding-top 3u
25
+
26
+ +native()
27
+ height auto
28
+
29
+ .actions
30
+ justify-content space-between
31
+
32
+ .rnPicker
33
+ margin-top 2u
34
+
35
+ .swipe
36
+ height 3u
37
+
38
+ .popoverWrapper
39
+ .popoverOverlay
40
+ position absolute
41
+ top 0
42
+ left 0
43
+ right 0
44
+ bottom 0
45
+
46
+ +web()
47
+ cursor default
48
+
49
+ .popover
50
+ background-color var(--color-bg-main-strong)
51
+ radius()
52
+ shadow(3)
53
+
package/index.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ /* eslint-disable */
2
+ // DO NOT MODIFY THIS FILE - IT IS AUTOMATICALLY GENERATED ON COMMITS.
3
+
4
+ import { type ReactNode, type RefObject } from 'react';
5
+ import { type StyleProp, type ViewStyle } from 'react-native';
6
+ import { type UITextInputProps } from '@startupjs-ui/text-input';
7
+ import './index.cssx.styl';
8
+ declare const _default: import("react").ComponentType<DateTimePickerProps>;
9
+ export default _default;
10
+ export declare const _PropsJsonSchema: {};
11
+ export interface DateTimePickerProps extends Omit<UITextInputProps, 'value' | 'onChangeText' | 'ref'> {
12
+ /** Ref to access underlying input */
13
+ ref?: RefObject<any>;
14
+ /** Custom styles applied to the root input */
15
+ style?: StyleProp<ViewStyle>;
16
+ /** Custom styles for content container */
17
+ contentStyle?: Record<string, any>;
18
+ /** Date formatting string from moment */
19
+ dateFormat?: string;
20
+ /** Picker display mode @default 'spinner' */
21
+ display?: 'default' | 'spinner' | 'calendar' | 'clock';
22
+ /** Minutes step for time selection @default 5 */
23
+ timeInterval?: number;
24
+ /** Force 24 hour time format (auto-detected when not provided) */
25
+ is24Hour?: boolean;
26
+ /** Input size preset @default 'm' */
27
+ size?: 'l' | 'm' | 's';
28
+ /** Picker type @default 'datetime' */
29
+ mode?: 'date' | 'time' | 'datetime' | 'countdown';
30
+ /** Custom renderer for input component */
31
+ renderInput?: (inputProps: Record<string, any> & {
32
+ onChangeVisible: (value: boolean) => void;
33
+ }) => ReactNode;
34
+ /** Locale identifier (falls back to device locale) */
35
+ locale?: string;
36
+ /** Selected date range (start/end timestamps) */
37
+ range?: [number, number];
38
+ /** IANA timezone name @default moment.tz.guess() */
39
+ timezone?: string;
40
+ /** Array of disabled day timestamps */
41
+ disabledDays?: number[];
42
+ /** Current value as timestamp */
43
+ date?: number;
44
+ /** Disable interactions @default false */
45
+ disabled?: boolean;
46
+ /** Render as readonly @default false */
47
+ readonly?: boolean;
48
+ /** Placeholder text */
49
+ placeholder?: string;
50
+ /** Maximum available date as timestamp */
51
+ maxDate?: number;
52
+ /** Minimum available date as timestamp */
53
+ minDate?: number;
54
+ /** Controlled visibility */
55
+ visible?: boolean;
56
+ /** Scoped model controlling visibility */
57
+ $visible?: any;
58
+ /** Test identifier for the input */
59
+ testID?: string;
60
+ /** Test identifier for calendar root */
61
+ calendarTestID?: string;
62
+ /** Called when user presses input (native) */
63
+ onPressIn?: (...args: any[]) => void;
64
+ /** Called with selected timestamp (or undefined when cleared) */
65
+ onChangeDate?: (value?: number) => void;
66
+ /** Called before opening (controlled visibility mode) */
67
+ onRequestOpen?: () => void;
68
+ /** Called before closing (controlled visibility mode) */
69
+ onRequestClose?: () => void;
70
+ /** Error state flag @private */
71
+ _hasError?: boolean;
72
+ }
package/index.tsx ADDED
@@ -0,0 +1,485 @@
1
+ import {
2
+ useMemo,
3
+ useRef,
4
+ useState,
5
+ useEffect,
6
+ useImperativeHandle,
7
+ type ReactNode,
8
+ type RefObject
9
+ } from 'react'
10
+ import { Platform, type StyleProp, type ViewStyle } from 'react-native'
11
+ import RNDateTimePicker, { DateTimePickerAndroid } from '@react-native-community/datetimepicker'
12
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
13
+ import { pug, observer, useBind, $ } from 'startupjs'
14
+ import { themed, useMedia } from '@startupjs-ui/core'
15
+ import Button from '@startupjs-ui/button'
16
+ import Div from '@startupjs-ui/div'
17
+ import Divider from '@startupjs-ui/divider'
18
+ import TextInput, { type UITextInputProps } from '@startupjs-ui/text-input'
19
+ import AbstractPopover from '@startupjs-ui/abstract-popover'
20
+ import Drawer from '@startupjs-ui/drawer'
21
+ import { faTimesCircle } from '@fortawesome/free-solid-svg-icons/faTimesCircle'
22
+ import moment from 'moment-timezone'
23
+ import { getLocale } from './helpers'
24
+ import { Calendar, TimeSelect } from './components'
25
+ import './index.cssx.styl'
26
+
27
+ export default observer(themed('DateTimePicker', DateTimePicker))
28
+
29
+ export const _PropsJsonSchema = {/* DateTimePickerProps */}
30
+
31
+ export interface DateTimePickerProps extends Omit<UITextInputProps, 'value' | 'onChangeText' | 'ref'> {
32
+ /** Ref to access underlying input */
33
+ ref?: RefObject<any>
34
+ /** Custom styles applied to the root input */
35
+ style?: StyleProp<ViewStyle>
36
+ /** Custom styles for content container */
37
+ contentStyle?: Record<string, any>
38
+ /** Date formatting string from moment */
39
+ dateFormat?: string
40
+ /** Picker display mode @default 'spinner' */
41
+ display?: 'default' | 'spinner' | 'calendar' | 'clock'
42
+ /** Minutes step for time selection @default 5 */
43
+ timeInterval?: number
44
+ /** Force 24 hour time format (auto-detected when not provided) */
45
+ is24Hour?: boolean
46
+ /** Input size preset @default 'm' */
47
+ size?: 'l' | 'm' | 's'
48
+ /** Picker type @default 'datetime' */
49
+ mode?: 'date' | 'time' | 'datetime' | 'countdown'
50
+ /** Custom renderer for input component */
51
+ renderInput?: (inputProps: Record<string, any> & { onChangeVisible: (value: boolean) => void }) => ReactNode
52
+ /** Locale identifier (falls back to device locale) */
53
+ locale?: string
54
+ /** Selected date range (start/end timestamps) */
55
+ range?: [number, number]
56
+ /** IANA timezone name @default moment.tz.guess() */
57
+ timezone?: string
58
+ /** Array of disabled day timestamps */
59
+ disabledDays?: number[]
60
+ /** Current value as timestamp */
61
+ date?: number
62
+ /** Disable interactions @default false */
63
+ disabled?: boolean
64
+ /** Render as readonly @default false */
65
+ readonly?: boolean
66
+ /** Placeholder text */
67
+ placeholder?: string
68
+ /** Maximum available date as timestamp */
69
+ maxDate?: number
70
+ /** Minimum available date as timestamp */
71
+ minDate?: number
72
+ /** Controlled visibility */
73
+ visible?: boolean
74
+ /** Scoped model controlling visibility */
75
+ $visible?: any
76
+ /** Test identifier for the input */
77
+ testID?: string
78
+ /** Test identifier for calendar root */
79
+ calendarTestID?: string
80
+ /** Called when user presses input (native) */
81
+ onPressIn?: (...args: any[]) => void
82
+ /** Called with selected timestamp (or undefined when cleared) */
83
+ onChangeDate?: (value?: number) => void
84
+ /** Called before opening (controlled visibility mode) */
85
+ onRequestOpen?: () => void
86
+ /** Called before closing (controlled visibility mode) */
87
+ onRequestClose?: () => void
88
+ /** Error state flag @private */
89
+ _hasError?: boolean
90
+ }
91
+
92
+ function DateTimePicker ({
93
+ style,
94
+ contentStyle = {},
95
+ dateFormat,
96
+ display = 'spinner',
97
+ timeInterval = 5,
98
+ is24Hour,
99
+ size = 'm',
100
+ mode = 'datetime',
101
+ renderInput,
102
+ locale,
103
+ range,
104
+ timezone = moment.tz.guess(),
105
+ disabledDays = [],
106
+ date,
107
+ disabled,
108
+ readonly,
109
+ placeholder,
110
+ maxDate,
111
+ minDate,
112
+ visible,
113
+ $visible,
114
+ testID,
115
+ calendarTestID,
116
+ onPressIn,
117
+ onChangeDate,
118
+ onRequestOpen,
119
+ onRequestClose,
120
+ _hasError,
121
+ ref,
122
+ ...props
123
+ }: DateTimePickerProps): ReactNode {
124
+ const media: any = useMedia()
125
+ const [textInput, setTextInput] = useState('')
126
+ const refTimeSelect = useRef<any>(null)
127
+ const inputRef = useRef<any>(null)
128
+ const insets = useSafeAreaInsets()
129
+
130
+ useImperativeHandle(ref, () => inputRef.current)
131
+
132
+ function onDismiss () {
133
+ onChangeVisible(false)
134
+ }
135
+
136
+ function getTimestampFromValue (value: any) {
137
+ if (value?.nativeEvent?.timestamp) {
138
+ return value.nativeEvent.timestamp
139
+ }
140
+
141
+ if (value instanceof Date) {
142
+ return value.getTime()
143
+ }
144
+
145
+ return value
146
+ }
147
+
148
+ function showAndroidPicker () {
149
+ const showTimepicker = (selectedDate: Date) => {
150
+ ;(DateTimePickerAndroid as any).open({
151
+ value: selectedDate,
152
+ mode: 'time',
153
+ display: 'time',
154
+ is24Hour,
155
+ onChange: (event: any, selectedTime: Date) => {
156
+ if (event.type === 'set') {
157
+ const finalDate = new Date(selectedDate)
158
+ finalDate.setHours(selectedTime.getHours())
159
+ finalDate.setMinutes(selectedTime.getMinutes())
160
+ _onChangeDate(finalDate)
161
+ } else {
162
+ onDismiss()
163
+ }
164
+ }
165
+ })
166
+ }
167
+
168
+ const showDatepicker = () => {
169
+ ;(DateTimePickerAndroid as any).open({
170
+ value: tempDate,
171
+ mode: 'date',
172
+ display: 'calendar',
173
+ maximumDate: maxDate ? new Date(maxDate) : undefined,
174
+ minimumDate: minDate ? new Date(minDate) : undefined,
175
+ onChange: (event: any, selectedDate: Date) => {
176
+ if (event.type === 'set') {
177
+ if (mode === 'datetime') {
178
+ showTimepicker(selectedDate)
179
+ } else {
180
+ _onChangeDate(selectedDate)
181
+ }
182
+ } else {
183
+ onDismiss()
184
+ }
185
+ }
186
+ })
187
+ }
188
+
189
+ switch (mode) {
190
+ case 'date':
191
+ showDatepicker()
192
+ break
193
+ case 'time':
194
+ ;(DateTimePickerAndroid as any).open({
195
+ value: tempDate,
196
+ mode: 'time',
197
+ display: 'clock',
198
+ is24Hour,
199
+ onChange: (event: any, selectedTime: Date) => {
200
+ if (event.type === 'set') {
201
+ _onChangeDate(selectedTime)
202
+ } else {
203
+ onDismiss()
204
+ }
205
+ }
206
+ })
207
+ break
208
+ case 'datetime':
209
+ showDatepicker()
210
+ break
211
+ }
212
+ }
213
+
214
+ let bindProps: any = useMemo(() => {
215
+ if (typeof $visible !== 'undefined') return { $visible }
216
+ if (typeof onRequestOpen === 'function' && typeof onRequestClose === 'function') {
217
+ return {
218
+ visible,
219
+ onChangeVisible: (value: boolean) => {
220
+ if (value) {
221
+ onRequestOpen()
222
+ } else {
223
+ onRequestClose()
224
+ }
225
+
226
+ if (Platform.OS === 'android' && value) {
227
+ showAndroidPicker()
228
+ }
229
+ }
230
+ }
231
+ }
232
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
233
+
234
+ if (!bindProps) {
235
+ $visible = $(false)
236
+ bindProps = { $visible }
237
+ }
238
+
239
+ let { onChangeVisible } = bindProps
240
+ ;({ visible, onChangeVisible } = useBind({ $visible, visible, onChangeVisible }) as any)
241
+
242
+ const [tempDate, setTempDate] = useTempDate({ visible: !!visible, date, timezone })
243
+
244
+ useEffect(() => {
245
+ // Prevent crashes when custom renderer passed via props
246
+ if (renderInput) return
247
+ if (visible) {
248
+ inputRef?.current?.focus?.()
249
+ } else {
250
+ inputRef?.current?.blur?.()
251
+ }
252
+ }, [visible, renderInput])
253
+
254
+ const exactLocale = useMemo(() => {
255
+ const locales = moment.locales()
256
+ const _locale = locale ?? getLocale()
257
+ return locales.includes(_locale) ? _locale : 'en'
258
+ }, [locale])
259
+
260
+ const _dateFormat = useMemo(() => {
261
+ if (dateFormat) return dateFormat
262
+ if (mode === 'datetime') {
263
+ return (moment().locale(exactLocale) as any)._locale._longDateFormat.L + ' ' +
264
+ (moment().locale(exactLocale) as any)._locale._longDateFormat.LT
265
+ }
266
+
267
+ if (mode === 'date') return (moment().locale(exactLocale) as any)._locale._longDateFormat.L
268
+ if (mode === 'time') return (moment().locale(exactLocale) as any)._locale._longDateFormat.LT
269
+ }, [mode, dateFormat, exactLocale])
270
+
271
+ useEffect(() => {
272
+ if (typeof date === 'undefined') {
273
+ setTextInput('')
274
+ return
275
+ }
276
+
277
+ const value = +moment.tz(date, timezone).seconds(0).milliseconds(0)
278
+ setTextInput(moment.tz(value, timezone).format(_dateFormat))
279
+ setTempDate(new Date(value))
280
+ }, [date, timezone, _dateFormat, setTempDate])
281
+
282
+ function _onChangeDate (value: any) {
283
+ const timestamp = getTimestampFromValue(value)
284
+ onChangeDate && onChangeDate(timestamp)
285
+ onChangeVisible(false)
286
+ }
287
+
288
+ function _onPressIn (...args: any[]) {
289
+ if (Platform.OS === 'android') {
290
+ showAndroidPicker()
291
+ }
292
+
293
+ onChangeVisible(true)
294
+
295
+ onPressIn && onPressIn(...args)
296
+ }
297
+
298
+ const inputProps: Record<string, any> = {
299
+ style,
300
+ ref: inputRef,
301
+ disabled,
302
+ readonly,
303
+ size,
304
+ placeholder,
305
+ _hasError,
306
+ value: textInput,
307
+ testID,
308
+ ...props
309
+ }
310
+
311
+ if (Platform.OS === 'web') {
312
+ inputProps.editable = false
313
+ inputProps.onFocus = _onPressIn
314
+ }
315
+
316
+ if (Platform.OS === 'android') {
317
+ inputProps.onFocus = _onPressIn
318
+ // Hide cursor for android
319
+ inputProps.cursorColor = 'transparent'
320
+ }
321
+
322
+ if (Platform.OS === 'ios') {
323
+ inputProps.onPressIn = _onPressIn
324
+ }
325
+
326
+ function handleRenderedInputPress (value: boolean) {
327
+ if (Platform.OS === 'android' && value) {
328
+ showAndroidPicker()
329
+ }
330
+
331
+ onChangeVisible(value)
332
+ }
333
+
334
+ const caption = pug`
335
+ if renderInput
336
+ = renderInput(Object.assign({ onChangeVisible: handleRenderedInputPress }, inputProps))
337
+ else
338
+ TextInput(
339
+ showSoftInputOnFocus=false
340
+ secondaryIcon=textInput && !renderInput ? faTimesCircle : undefined,
341
+ onSecondaryIconPress=() => onChangeDate && onChangeDate()
342
+ ...inputProps
343
+ )
344
+ `
345
+
346
+ function renderPopoverContent (): ReactNode {
347
+ return pug`
348
+ Div.content(
349
+ style={
350
+ paddingBottom: (media.tablet && Platform.OS === 'ios') ? 0 : insets.bottom,
351
+ ...contentStyle
352
+ }
353
+ )
354
+ if Platform.OS === 'web'
355
+ if (mode === 'date') || (mode === 'datetime')
356
+ Calendar(
357
+ date=tempDate
358
+ exactLocale=exactLocale
359
+ disabledDays=disabledDays
360
+ locale=locale
361
+ maxDate=maxDate
362
+ minDate=minDate
363
+ range=range
364
+ timezone=timezone
365
+ testID=calendarTestID
366
+ onChangeDate=(newDate) => {
367
+ setTempDate(newDate)
368
+ if (mode === 'date') _onChangeDate(newDate)
369
+ }
370
+ )
371
+
372
+ if mode === 'datetime'
373
+ Divider.divider
374
+
375
+ if (mode === 'time') || (mode === 'datetime')
376
+ TimeSelect(
377
+ date=tempDate
378
+ ref=refTimeSelect
379
+ maxDate=maxDate
380
+ minDate=minDate
381
+ timezone=timezone
382
+ exactLocale=exactLocale
383
+ is24Hour=is24Hour
384
+ timeInterval=timeInterval
385
+ onChangeDate=(newTime) => {
386
+ const finalDate = new Date(tempDate)
387
+ finalDate.setHours(new Date(newTime).getHours())
388
+ finalDate.setMinutes(new Date(newTime).getMinutes())
389
+ _onChangeDate(finalDate)
390
+ }
391
+ )
392
+ else if Platform.OS === 'ios'
393
+ Div.actions(row)
394
+ Button(
395
+ size='s'
396
+ color='secondary'
397
+ variant='text'
398
+ onPress=() => {
399
+ onDismiss()
400
+ }
401
+ ) Cancel
402
+ Button(
403
+ size='s'
404
+ color='primary'
405
+ variant='text'
406
+ onPress=() => {
407
+ _onChangeDate(tempDate)
408
+ }
409
+ ) Done
410
+ RNDateTimePicker.rnPicker(
411
+ value=tempDate
412
+ display=display
413
+ is24Hour=is24Hour
414
+ disabled=disabled
415
+ mode=mode
416
+ themeVariant='light'
417
+ textColor='#000000cc'
418
+ maximumDate=maxDate ? new Date(maxDate) : undefined
419
+ minimumDate=minDate ? new Date(minDate) : undefined
420
+ timeZoneName=timezone
421
+ onChange=(event, selectedDate) => {
422
+ if (event.type !== 'dismissed') {
423
+ setTempDate(selectedDate)
424
+ }
425
+ }
426
+ )
427
+ `
428
+ }
429
+
430
+ function renderWrapper (children: ReactNode): ReactNode {
431
+ return pug`
432
+ Div.popoverWrapper
433
+ Div.popoverOverlay(feedback=false onPress=()=> onChangeVisible(false))
434
+ = children
435
+ `
436
+ }
437
+
438
+ return pug`
439
+ // Android datetimepicker rendered inside its own modal
440
+ if Platform.OS === 'android'
441
+ = caption
442
+ else
443
+ if media.tablet
444
+ = caption
445
+ AbstractPopover.popover(
446
+ visible=visible
447
+ anchorRef=inputRef
448
+ renderWrapper=renderWrapper
449
+ )= renderPopoverContent()
450
+ else
451
+ = caption
452
+ Drawer.drawer(
453
+ visible=visible
454
+ position='bottom'
455
+ swipeStyleName='swipe'
456
+ AreaComponent=Div
457
+ onDismiss=onDismiss
458
+ )= renderPopoverContent()
459
+ `
460
+ }
461
+
462
+ function useTempDate ({
463
+ visible,
464
+ date,
465
+ timezone
466
+ }: {
467
+ visible: boolean
468
+ date?: number
469
+ timezone: string
470
+ }) {
471
+ const [tempDate, setTempDate] = useState(getTempDate(date, timezone))
472
+
473
+ useEffect(() => {
474
+ const tempDate = getTempDate(date, timezone)
475
+ setTempDate(tempDate)
476
+ }, [visible, date, timezone])
477
+
478
+ return [tempDate, setTempDate] as const
479
+ }
480
+
481
+ function getTempDate (date: number | undefined, timezone: string) {
482
+ return date
483
+ ? new Date(+moment.tz(date, timezone).seconds(0).milliseconds(0))
484
+ : new Date()
485
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@startupjs-ui/date-time-picker",
3
+ "version": "0.1.3",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "main": "index.tsx",
8
+ "types": "index.d.ts",
9
+ "type": "module",
10
+ "dependencies": {
11
+ "@fortawesome/free-solid-svg-icons": "^5.12.0",
12
+ "@react-native-community/datetimepicker": "^8.4.4",
13
+ "@startupjs-ui/abstract-popover": "^0.1.3",
14
+ "@startupjs-ui/button": "^0.1.3",
15
+ "@startupjs-ui/core": "^0.1.3",
16
+ "@startupjs-ui/div": "^0.1.3",
17
+ "@startupjs-ui/divider": "^0.1.3",
18
+ "@startupjs-ui/drawer": "^0.1.3",
19
+ "@startupjs-ui/flat-list": "^0.1.3",
20
+ "@startupjs-ui/icon": "^0.1.3",
21
+ "@startupjs-ui/popover": "^0.1.3",
22
+ "@startupjs-ui/span": "^0.1.3",
23
+ "@startupjs-ui/text-input": "^0.1.3",
24
+ "moment-timezone": "^0.5.31",
25
+ "react-native-safe-area-context": "^5.6.0"
26
+ },
27
+ "peerDependencies": {
28
+ "react": "*",
29
+ "react-native": "*",
30
+ "startupjs": "*"
31
+ },
32
+ "gitHead": "fd964ebc3892d3dd0a6c85438c0af619cc50c3f0"
33
+ }