@consumidor-positivo/aurora 1.2.0 → 1.3.0
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 +59 -3
- package/astro/AdBox/index.astro +28 -0
- package/astro/Alert/index.astro +257 -0
- package/astro/BadgeInfo/index.astro +55 -0
- package/astro/BadgeState/index.astro +79 -0
- package/astro/Button/index.astro +11 -3
- package/astro/Card/Container/index.astro +44 -0
- package/astro/Card/Emphasis/index.astro +39 -0
- package/astro/Card/Image/index.astro +26 -0
- package/astro/Card/Root/index.astro +53 -0
- package/astro/Card/Tag/index.astro +26 -0
- package/astro/Chip/index.astro +48 -0
- package/astro/ChipBanner/index.astro +160 -0
- package/astro/Container/index.astro +16 -0
- package/astro/Divider/index.astro +27 -0
- package/astro/Image/index.astro +26 -0
- package/astro/LazyImage/index.astro +55 -0
- package/astro/LinkButton/index.astro +54 -0
- package/astro/Logo/ac/BadgeFullWhite.astro +20 -0
- package/astro/Logo/ac/BadgeWhite.astro +23 -0
- package/astro/Logo/ac/BadgeWhiteStroke.astro +27 -0
- package/astro/Logo/ac/BadgeWhiteStrokeDefault.astro +28 -0
- package/astro/Logo/ac/Badget.astro +24 -0
- package/astro/Logo/ac/Primary.astro +28 -0
- package/astro/Logo/ac/PrimaryFillWhite.astro +28 -0
- package/astro/Logo/ac/PrimaryFullWhite.astro +20 -0
- package/astro/Logo/ac/PrimaryNegative.astro +20 -0
- package/astro/Logo/ac/PrimaryWhite.astro +20 -0
- package/astro/Logo/ac/TertiaryWhite.astro +72 -0
- package/astro/Logo/cp/BadgeWhiteStroke.astro +10 -0
- package/astro/Logo/cp/Badget.astro +14 -0
- package/astro/Logo/cp/PrimaryFillWhite.astro +63 -0
- package/astro/Logo/cp/PrimaryFullWhite.astro +27 -0
- package/astro/Logo/cp/PrimaryLogoWhite.astro +67 -0
- package/astro/Logo/cp/PrimaryNegative.astro +27 -0
- package/astro/Logo/cp/PrimaryWhite.astro +27 -0
- package/astro/Modal/index.astro +213 -0
- package/astro/NotificationsBar/Link/index.astro +70 -0
- package/astro/NotificationsBar/List/index.astro +38 -0
- package/astro/NotificationsBar/index.astro +24 -0
- package/astro/PartnerBanner/index.astro +52 -0
- package/astro/ProfileNav/index.astro +34 -0
- package/astro/ProgressBar/index.astro +70 -0
- package/astro/Skeleton/index.astro +50 -0
- package/astro/SpecialButton/index.astro +331 -0
- package/astro/Spinner/index.astro +75 -0
- package/astro/SubHeader/index.astro +57 -0
- package/astro/Switch/Card/index.astro +69 -0
- package/astro/Switch/Pure/index.astro +47 -0
- package/astro/Tooltip/index.astro +38 -0
- package/astro/form/Checkbox/Field/index.astro +68 -0
- package/astro/form/Checkbox/Group/index.astro +79 -0
- package/astro/form/Datepicker/Calendar/index.astro +53 -0
- package/astro/form/Datepicker/CalendarHeader/index.astro +57 -0
- package/astro/form/Datepicker/Segment/index.astro +53 -0
- package/astro/form/Datepicker/index.astro +756 -0
- package/astro/form/EmailField/index.astro +237 -0
- package/astro/form/Field/ErrorMessage/index.astro +20 -0
- package/astro/form/Field/Input/index.astro +15 -0
- package/astro/form/Field/InputHolder/index.astro +28 -0
- package/astro/form/Field/Label/index.astro +80 -0
- package/astro/form/Field/Message/index.astro +29 -0
- package/astro/form/Field/Root/index.astro +37 -0
- package/astro/form/Field/TextArea/index.astro +69 -0
- package/astro/form/InputField/index.astro +96 -0
- package/astro/form/PasswordField/index.astro +123 -0
- package/astro/form/Radio/Field/index.astro +52 -0
- package/astro/form/Radio/Group/index.astro +78 -0
- package/astro/form/SelectField/index.astro +561 -0
- package/astro/form/TextareaField/index.astro +81 -0
- package/astro/form/TokenField/index.astro +211 -0
- package/astro/tokens/package.json +6 -0
- package/dist/{tokens-C4ypB4bY.js → astro/tokens/index.es.js} +101 -101
- package/dist/astro/tokens/index.es.js.map +1 -0
- package/dist/components/Alert/index.es.js +1 -1
- package/dist/components/BadgeInfo/index.es.js +1 -1
- package/dist/components/BadgeState/index.es.js +1 -1
- package/dist/components/Image/index.es.js +1 -1
- package/dist/components/Spinner/index.es.js +1 -1
- package/dist/components/form/Checkbox/Field/index.es.js +1 -1
- package/dist/components/form/Datepicker/Calendar/index.es.js +2 -2
- package/dist/components/form/Datepicker/CalendarHeader/index.es.js +2 -2
- package/dist/components/form/Datepicker/PortalHolder/index.es.js +1 -1
- package/dist/components/form/Datepicker/Segment/index.es.js +1 -1
- package/dist/components/form/Datepicker/index.es.js +3 -3
- package/dist/components/form/Field/Label/index.es.js +1 -1
- package/dist/components/form/SelectField/index.es.js +1 -1
- package/dist/{index-BcC9liUl.js → index-Bkl5OBIY.js} +3 -3
- package/dist/{index-BcC9liUl.js.map → index-Bkl5OBIY.js.map} +1 -1
- package/dist/{index-CFYmtkje.js → index-CZfMkAoF.js} +2 -2
- package/dist/{index-CFYmtkje.js.map → index-CZfMkAoF.js.map} +1 -1
- package/dist/{index-CeClo_WS.js → index-Cw1KtRQr.js} +2 -2
- package/dist/{index-CeClo_WS.js.map → index-Cw1KtRQr.js.map} +1 -1
- package/dist/main.es.js +101 -101
- package/package.json +7 -1
- package/dist/tokens-C4ypB4bY.js.map +0 -1
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types'
|
|
3
|
+
import InputField from '../InputField/index.astro'
|
|
4
|
+
import DatepickerCalendar from './Calendar/index.astro'
|
|
5
|
+
import IconCalendar from '../../icons/default/IconCalendar.astro'
|
|
6
|
+
import { COLOR_NEUTRAL_40 } from '@consumidor-positivo/aurora/astro/tokens'
|
|
7
|
+
import '../../../dist/components/Datepicker/styles.css'
|
|
8
|
+
|
|
9
|
+
export type AUCalendarDateShape = { day: number; month: number; year: number }
|
|
10
|
+
export type DefaultValue = 'empty' | 'now' | AUCalendarDateShape
|
|
11
|
+
|
|
12
|
+
type FieldProps = Pick<
|
|
13
|
+
HTMLAttributes<'input'>,
|
|
14
|
+
'id' | 'name' | 'required' | 'autocomplete' | 'style' | 'class'
|
|
15
|
+
>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Same props and markup as the React `DatepickerField`: an `InputField` with
|
|
19
|
+
* the DD/MM/YYYY mask and the calendar dialog. The React grid comes from
|
|
20
|
+
* `react-aria-components` 1.17; the inline controller reproduces its markup
|
|
21
|
+
* (roles, `aria-label` per day from `Intl`, roving `tabindex`, the `data-*`
|
|
22
|
+
* states the stylesheet uses), its keyboard (arrows, Home, End, PageUp,
|
|
23
|
+
* PageDown, Enter, space) and its live-region announcements, on top of the
|
|
24
|
+
* Aurora hooks it ports step by step: the mask and validation, the month and
|
|
25
|
+
* year pickers, the mobile sheet with Cancel and Confirm, the portal below
|
|
26
|
+
* 600px and the outside click above it.
|
|
27
|
+
*
|
|
28
|
+
* Differences from React: `value` is the initial date; `onChange` becomes
|
|
29
|
+
* `au:datechange` (`detail.value`, the date or `null` for an invalid one) and
|
|
30
|
+
* `onBlur` becomes `au:dateblur` (`detail.value`), both bubbling from the root.
|
|
31
|
+
* `format` has no counterpart: only the DD/MM/YYYY adapter ships. With
|
|
32
|
+
* `defaultValue="now"` the field starts empty in the HTML and the controller
|
|
33
|
+
* fills today, since "now" is the browser's day, not the build's. The
|
|
34
|
+
* calendar strings follow the browser language like react-aria; pt-BR and
|
|
35
|
+
* en-US are bundled, other languages fall back to en-US. Every other HTML
|
|
36
|
+
* attribute goes to the `<input>`, as the React spreads its props.
|
|
37
|
+
*/
|
|
38
|
+
export type Props = FieldProps & {
|
|
39
|
+
label?: string
|
|
40
|
+
showOptionalLabel?: boolean
|
|
41
|
+
requiredInput?: boolean
|
|
42
|
+
success?: boolean
|
|
43
|
+
error?: boolean
|
|
44
|
+
errorMessage?: string
|
|
45
|
+
helpMessage?: string
|
|
46
|
+
disabled?: boolean
|
|
47
|
+
/** Use the calendar dialog to pick a date. */
|
|
48
|
+
calendar?: boolean
|
|
49
|
+
placeholder?: string
|
|
50
|
+
/** Initial date. */
|
|
51
|
+
value?: AUCalendarDateShape | null
|
|
52
|
+
/** Default date, as the React uncontrolled mode. */
|
|
53
|
+
defaultValue?: DefaultValue
|
|
54
|
+
/** Makes the calendar dialog the top z-indexed item on the page (below 600px). */
|
|
55
|
+
withPortal?: boolean
|
|
56
|
+
/** Min allowed date, defaults to 1900-01-01. */
|
|
57
|
+
minValue?: AUCalendarDateShape
|
|
58
|
+
/** Max allowed date, defaults to 2100-12-31. */
|
|
59
|
+
maxValue?: AUCalendarDateShape
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const {
|
|
63
|
+
calendar = true,
|
|
64
|
+
placeholder,
|
|
65
|
+
disabled = false,
|
|
66
|
+
value,
|
|
67
|
+
defaultValue = 'empty',
|
|
68
|
+
withPortal = true,
|
|
69
|
+
minValue = { day: 1, month: 1, year: 1900 },
|
|
70
|
+
maxValue = { day: 31, month: 12, year: 2100 },
|
|
71
|
+
...rest
|
|
72
|
+
} = Astro.props
|
|
73
|
+
|
|
74
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
75
|
+
const toString = (date: AUCalendarDateShape) => `${pad(date.day)}/${pad(date.month)}/${date.year}`
|
|
76
|
+
const toIso = (date: AUCalendarDateShape) => `${date.year}-${pad(date.month)}-${pad(date.day)}`
|
|
77
|
+
|
|
78
|
+
// Same precedence as `useDatepicker`: `value` wins, then a dated `defaultValue`.
|
|
79
|
+
const initial: AUCalendarDateShape | null =
|
|
80
|
+
value && value.day ? value : typeof defaultValue === 'object' ? defaultValue : null
|
|
81
|
+
const inputDate = initial ? toString(initial) : ''
|
|
82
|
+
const focused = initial ?? null
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
<div
|
|
86
|
+
class="au-datepicker"
|
|
87
|
+
data-element="au-datepicker"
|
|
88
|
+
data-au-datepicker-min={toIso(minValue)}
|
|
89
|
+
data-au-datepicker-max={toIso(maxValue)}
|
|
90
|
+
data-au-datepicker-selected={initial ? toIso(initial) : undefined}
|
|
91
|
+
data-au-datepicker-now={defaultValue === 'now' && !initial ? '' : undefined}
|
|
92
|
+
data-au-datepicker-portal={withPortal ? '' : undefined}
|
|
93
|
+
data-au-datepicker-calendar={calendar ? '' : undefined}>
|
|
94
|
+
<InputField
|
|
95
|
+
class="au-datepicker__input"
|
|
96
|
+
{...rest}
|
|
97
|
+
maxlength={10}
|
|
98
|
+
value={inputDate}
|
|
99
|
+
type="tel"
|
|
100
|
+
disabled={disabled}
|
|
101
|
+
placeholder={placeholder || 'DD/MM/YYYY'}>
|
|
102
|
+
{
|
|
103
|
+
calendar && (
|
|
104
|
+
<button
|
|
105
|
+
slot="right"
|
|
106
|
+
type="button"
|
|
107
|
+
class="au-datepicker__calendar-holder"
|
|
108
|
+
aria-label="Abrir calendário">
|
|
109
|
+
<IconCalendar rawColor={disabled ? COLOR_NEUTRAL_40 : undefined} />
|
|
110
|
+
</button>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
</InputField>
|
|
114
|
+
{
|
|
115
|
+
calendar && (
|
|
116
|
+
<template data-au-datepicker-calendar>
|
|
117
|
+
<DatepickerCalendar
|
|
118
|
+
minYear={minValue.year}
|
|
119
|
+
maxYear={maxValue.year}
|
|
120
|
+
month={focused?.month ?? 1}
|
|
121
|
+
year={focused?.year ?? minValue.year}
|
|
122
|
+
/>
|
|
123
|
+
</template>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<script>
|
|
129
|
+
import { elementController } from '@consumidor-positivo/aurora/astro/runtime'
|
|
130
|
+
|
|
131
|
+
type DateShape = { day: number; month: number; year: number }
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Dates. Plain gregorian arithmetic, the subset of @internationalized/date
|
|
135
|
+
// the React Datepicker uses.
|
|
136
|
+
const BREAKPOINT_MD = '600px'
|
|
137
|
+
const above = (breakpoint: string) => window.matchMedia(`screen and (min-width: ${breakpoint})`).matches
|
|
138
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
139
|
+
const toNative = (d: DateShape) => new Date(d.year, d.month - 1, d.day)
|
|
140
|
+
const fromNative = (n: Date): DateShape => ({ day: n.getDate(), month: n.getMonth() + 1, year: n.getFullYear() })
|
|
141
|
+
const daysInMonth = (year: number, month: number) => new Date(year, month, 0).getDate()
|
|
142
|
+
const compare = (a: DateShape, b: DateShape) => a.year - b.year || a.month - b.month || a.day - b.day
|
|
143
|
+
const isSameDay = (a: DateShape | null | undefined, b: DateShape | null | undefined) => !!a && !!b && compare(a, b) === 0
|
|
144
|
+
const addDays = (d: DateShape, days: number) => fromNative(new Date(d.year, d.month - 1, d.day + days))
|
|
145
|
+
// `CalendarDate.add({months})` keeps the day and constrains it to the month.
|
|
146
|
+
const addMonths = (d: DateShape, months: number) => {
|
|
147
|
+
const total = d.year * 12 + (d.month - 1) + months
|
|
148
|
+
const year = Math.floor(total / 12)
|
|
149
|
+
const month = (total % 12) + 1
|
|
150
|
+
return { year, month, day: Math.min(d.day, daysInMonth(year, month)) }
|
|
151
|
+
}
|
|
152
|
+
const addYears = (d: DateShape, years: number) => ({
|
|
153
|
+
year: d.year + years,
|
|
154
|
+
month: d.month,
|
|
155
|
+
day: Math.min(d.day, daysInMonth(d.year + years, d.month)),
|
|
156
|
+
})
|
|
157
|
+
const startOfMonth = (d: DateShape): DateShape => ({ ...d, day: 1 })
|
|
158
|
+
const endOfMonth = (d: DateShape): DateShape => ({ ...d, day: daysInMonth(d.year, d.month) })
|
|
159
|
+
const today = () => fromNative(new Date())
|
|
160
|
+
const parseIso = (iso: string): DateShape => {
|
|
161
|
+
const [year, month, day] = iso.split('-').map(Number)
|
|
162
|
+
return { year, month, day }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// First day of the week per region (CLDR, as @internationalized/date ships
|
|
166
|
+
// it; regions that start on Sunday are omitted).
|
|
167
|
+
const WEEK_START: Record<string, number> = { '001': 1, AD: 1, AE: 6, AF: 6, AI: 1, AL: 1, AM: 1, AN: 1, AR: 1, AT: 1, AU: 1, AX: 1, AZ: 1, BA: 1, BE: 1, BG: 1, BH: 6, BM: 1, BN: 1, BY: 1, CH: 1, CL: 1, CM: 1, CN: 1, CR: 1, CY: 1, CZ: 1, DE: 1, DJ: 6, DK: 1, DZ: 6, EC: 1, EE: 1, EG: 6, ES: 1, FI: 1, FJ: 1, FO: 1, FR: 1, GB: 1, GE: 1, GF: 1, GP: 1, GR: 1, HR: 1, HU: 1, IE: 1, IQ: 6, IR: 6, IS: 1, IT: 1, JO: 6, KG: 1, KW: 6, KZ: 1, LB: 1, LI: 1, LK: 1, LT: 1, LU: 1, LV: 1, LY: 6, MC: 1, MD: 1, ME: 1, MK: 1, MN: 1, MQ: 1, MV: 5, MY: 1, NL: 1, NO: 1, NZ: 1, OM: 6, PL: 1, QA: 6, RE: 1, RO: 1, RS: 1, RU: 1, SD: 6, SE: 1, SI: 1, SK: 1, SM: 1, SY: 6, TJ: 1, TM: 1, TR: 1, UA: 1, UY: 1, UZ: 1, VA: 1, VN: 1, XK: 1 }
|
|
168
|
+
function getWeekStart(locale: string) {
|
|
169
|
+
try {
|
|
170
|
+
const inst = new Intl.Locale(locale)
|
|
171
|
+
const info = (inst as unknown as { getWeekInfo?: () => { firstDay: number } }).getWeekInfo?.()
|
|
172
|
+
if (info) return info.firstDay % 7
|
|
173
|
+
const region = inst.maximize().region
|
|
174
|
+
return region ? WEEK_START[region] || 0 : 0
|
|
175
|
+
} catch {
|
|
176
|
+
return 0
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The react-aria calendar strings the Datepicker reaches.
|
|
181
|
+
const STRINGS: Record<string, Record<string, string>> = {
|
|
182
|
+
'pt-BR': {
|
|
183
|
+
next: 'Próximo',
|
|
184
|
+
minimumDate: 'Primeira data disponível',
|
|
185
|
+
maximumDate: 'Última data disponível',
|
|
186
|
+
dateSelected: '{date} selecionado',
|
|
187
|
+
todayDate: 'Hoje, {date}',
|
|
188
|
+
todayDateSelected: 'Hoje, {date} selecionado',
|
|
189
|
+
selectedDateDescription: 'Data selecionada: {date}',
|
|
190
|
+
},
|
|
191
|
+
'en-US': {
|
|
192
|
+
next: 'Next',
|
|
193
|
+
minimumDate: 'First available date',
|
|
194
|
+
maximumDate: 'Last available date',
|
|
195
|
+
dateSelected: '{date} selected',
|
|
196
|
+
todayDate: 'Today, {date}',
|
|
197
|
+
todayDateSelected: 'Today, {date} selected',
|
|
198
|
+
selectedDateDescription: 'Selected Date: {date}',
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Same DOM as react-aria's LiveAnnouncer, created on first use.
|
|
203
|
+
let announcerNode: HTMLElement | null = null
|
|
204
|
+
function announce(message: string, assertiveness: 'assertive' | 'polite' = 'assertive', timeout = 7000) {
|
|
205
|
+
if (!announcerNode) {
|
|
206
|
+
announcerNode = document.createElement('div')
|
|
207
|
+
announcerNode.dataset.liveAnnouncer = 'true'
|
|
208
|
+
Object.assign(announcerNode.style, {
|
|
209
|
+
border: '0',
|
|
210
|
+
clip: 'rect(0 0 0 0)',
|
|
211
|
+
clipPath: 'inset(50%)',
|
|
212
|
+
height: '1px',
|
|
213
|
+
margin: '-1px',
|
|
214
|
+
overflow: 'hidden',
|
|
215
|
+
padding: '0',
|
|
216
|
+
position: 'absolute',
|
|
217
|
+
width: '1px',
|
|
218
|
+
whiteSpace: 'nowrap',
|
|
219
|
+
})
|
|
220
|
+
for (const live of ['assertive', 'polite']) {
|
|
221
|
+
const log = document.createElement('div')
|
|
222
|
+
log.setAttribute('role', 'log')
|
|
223
|
+
log.setAttribute('aria-live', live)
|
|
224
|
+
log.setAttribute('aria-relevant', 'additions')
|
|
225
|
+
announcerNode.appendChild(log)
|
|
226
|
+
}
|
|
227
|
+
document.body.prepend(announcerNode)
|
|
228
|
+
}
|
|
229
|
+
const log = announcerNode.children[assertiveness === 'assertive' ? 0 : 1]
|
|
230
|
+
const node = document.createElement('div')
|
|
231
|
+
node.textContent = message
|
|
232
|
+
log.appendChild(node)
|
|
233
|
+
if (message !== '') window.setTimeout(() => node.remove(), timeout)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// react-aria's interaction modality: keyboard focus shows `data-focus-visible`.
|
|
237
|
+
let modality: 'keyboard' | 'pointer' = 'pointer'
|
|
238
|
+
const toPointer = () => {
|
|
239
|
+
modality = 'pointer'
|
|
240
|
+
document.querySelectorAll('.au-datepicker__calendar-day[data-focus-visible]').forEach((cell) => cell.removeAttribute('data-focus-visible'))
|
|
241
|
+
}
|
|
242
|
+
document.addEventListener('keydown', () => { modality = 'keyboard' }, true)
|
|
243
|
+
document.addEventListener('pointerdown', toPointer, true)
|
|
244
|
+
document.addEventListener('pointermove', toPointer, true)
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
elementController('au-datepicker', ({ root, query, emit }) => {
|
|
248
|
+
const input = query<HTMLInputElement>('.au-field__input')
|
|
249
|
+
const template = query<HTMLTemplateElement>('template[data-au-datepicker-calendar]')
|
|
250
|
+
if (!input) return
|
|
251
|
+
const field = input
|
|
252
|
+
|
|
253
|
+
// Controller settings only; React's root carries no such attributes.
|
|
254
|
+
const minValue = parseIso(root.dataset.auDatepickerMin ?? '1900-01-01')
|
|
255
|
+
const maxValue = parseIso(root.dataset.auDatepickerMax ?? '2100-12-31')
|
|
256
|
+
const withPortal = root.hasAttribute('data-au-datepicker-portal')
|
|
257
|
+
const hasCalendar = root.hasAttribute('data-au-datepicker-calendar')
|
|
258
|
+
const startsNow = root.hasAttribute('data-au-datepicker-now')
|
|
259
|
+
let selectedDate: DateShape | null = root.dataset.auDatepickerSelected ? parseIso(root.dataset.auDatepickerSelected) : null
|
|
260
|
+
for (const name of ['min', 'max', 'selected', 'now', 'portal', 'calendar']) root.removeAttribute(`data-au-datepicker-${name}`)
|
|
261
|
+
|
|
262
|
+
const disabled = field.disabled
|
|
263
|
+
const locale = navigator.language || 'en-US'
|
|
264
|
+
const strings = STRINGS[locale] ?? STRINGS[locale.split('-')[0] === 'pt' ? 'pt-BR' : 'en-US']
|
|
265
|
+
const t = (key: string, date = '') => strings[key].replace('{date}', date)
|
|
266
|
+
const monthFormatter = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })
|
|
267
|
+
const dayLabelFormatter = new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
|
268
|
+
const dayNumberFormatter = new Intl.DateTimeFormat(locale, { day: 'numeric' })
|
|
269
|
+
const weekdayFormatter = new Intl.DateTimeFormat(locale, { weekday: 'short' })
|
|
270
|
+
const weekStart = getWeekStart(locale)
|
|
271
|
+
|
|
272
|
+
// ---- the field (useDatepicker) -----------------------------------------
|
|
273
|
+
const format = {
|
|
274
|
+
placeholder: 'DD/MM/YYYY',
|
|
275
|
+
maskDate(dateStr: string) {
|
|
276
|
+
let fmt = dateStr.replace(/\D/g, '')
|
|
277
|
+
if (fmt.length > 2) fmt = `${fmt.slice(0, 2)}/${fmt.slice(2)}`
|
|
278
|
+
if (fmt.length > 5) fmt = `${fmt.slice(0, 5)}/${fmt.slice(5)}`
|
|
279
|
+
return fmt
|
|
280
|
+
},
|
|
281
|
+
validateFormat: (dateStr: string) => /^\d{2}\/\d{2}\/\d{4}$/.test(dateStr),
|
|
282
|
+
toCalendarDate(dateStr: string): DateShape {
|
|
283
|
+
const [day, month, year] = dateStr.split('/').map(Number)
|
|
284
|
+
return { day, month, year }
|
|
285
|
+
},
|
|
286
|
+
toString: (d: DateShape) => `${pad(d.day)}/${pad(d.month)}/${d.year}`,
|
|
287
|
+
validate(dateStr: string) {
|
|
288
|
+
const [day, month, year] = dateStr.split('/').map(Number)
|
|
289
|
+
const date = new Date(year, month - 1, day)
|
|
290
|
+
const olderThanMin = date < toNative(minValue)
|
|
291
|
+
const exceedsMax = date > toNative(maxValue)
|
|
292
|
+
return date.getDate() === day && date.getMonth() + 1 === month && date.getFullYear() === year && !olderThanMin && !exceedsMax
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// `isMobileInputWithCalendar`, computed once like the React `useMemo`.
|
|
297
|
+
if (!above(BREAKPOINT_MD) && hasCalendar) field.setAttribute('inputmode', 'none')
|
|
298
|
+
|
|
299
|
+
if (startsNow && !selectedDate) {
|
|
300
|
+
selectedDate = today()
|
|
301
|
+
field.value = format.toString(selectedDate)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let isCalendarVisible = false
|
|
305
|
+
|
|
306
|
+
function handleLoseFocus(event: MouseEvent) {
|
|
307
|
+
if (!root.contains(event.target as Node)) {
|
|
308
|
+
closeCalendar()
|
|
309
|
+
document.removeEventListener('mousedown', handleLoseFocus)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// `useOutsideClick` with the MD breakpoint: only above it.
|
|
313
|
+
function listenOutsideClick() {
|
|
314
|
+
if (!above(BREAKPOINT_MD)) return
|
|
315
|
+
document.addEventListener('mousedown', handleLoseFocus)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function handleInputChange() {
|
|
319
|
+
const maskedValue = format.maskDate(field.value)
|
|
320
|
+
field.value = maskedValue
|
|
321
|
+
if (!format.validateFormat(maskedValue)) return
|
|
322
|
+
const isDateValid = format.validate(maskedValue)
|
|
323
|
+
const newFieldValue = isDateValid ? format.toCalendarDate(maskedValue) : null
|
|
324
|
+
selectedDate = newFieldValue
|
|
325
|
+
// The React calendar only follows a dated `value` (`if (!value) return`),
|
|
326
|
+
// so an invalid date leaves the previous selection in the calendar.
|
|
327
|
+
if (newFieldValue) calendar?.setValue(newFieldValue)
|
|
328
|
+
emit('au:datechange', { value: newFieldValue })
|
|
329
|
+
if (!isDateValid) field.value = ''
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function openCalendar() {
|
|
333
|
+
if (!isCalendarVisible && disabled) return
|
|
334
|
+
listenOutsideClick()
|
|
335
|
+
if (isCalendarVisible) return
|
|
336
|
+
isCalendarVisible = true
|
|
337
|
+
calendar?.show()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function closeCalendar() {
|
|
341
|
+
if (!isCalendarVisible) return
|
|
342
|
+
isCalendarVisible = false
|
|
343
|
+
calendar?.hide()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function updateDateFromCalendar(pickerDate: DateShape) {
|
|
347
|
+
selectedDate = pickerDate
|
|
348
|
+
field.value = format.toString(pickerDate)
|
|
349
|
+
calendar?.setValue(pickerDate)
|
|
350
|
+
emit('au:datechange', { value: pickerDate })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
field.addEventListener('input', handleInputChange)
|
|
354
|
+
field.addEventListener('focus', openCalendar)
|
|
355
|
+
field.addEventListener('blur', () => emit('au:dateblur', { value: selectedDate }))
|
|
356
|
+
query<HTMLButtonElement>('.au-datepicker__calendar-holder')?.addEventListener('click', () => field.focus())
|
|
357
|
+
|
|
358
|
+
// ---- the calendar (useCalendar + react-aria) ---------------------------
|
|
359
|
+
type Calendar = {
|
|
360
|
+
show(): void
|
|
361
|
+
hide(): void
|
|
362
|
+
setValue(date: DateShape | null): void
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const calendar: Calendar | null = template ? createCalendar(template) : null
|
|
366
|
+
|
|
367
|
+
function createCalendar(template: HTMLTemplateElement): Calendar {
|
|
368
|
+
const calendarEl = template.content.firstElementChild as HTMLElement
|
|
369
|
+
template.remove()
|
|
370
|
+
const base = calendarEl.querySelector<HTMLElement>('.au-datepicker__calendar-base')!
|
|
371
|
+
const title = base.querySelector<HTMLElement>(':scope > div > h2')!
|
|
372
|
+
const grid = base.querySelector<HTMLTableElement>('table')!
|
|
373
|
+
const headerCells = Array.from(grid.querySelectorAll<HTMLElement>('thead th'))
|
|
374
|
+
const body = grid.querySelector<HTMLElement>('tbody')!
|
|
375
|
+
const hiddenNext = base.querySelector<HTMLButtonElement>(':scope > div:last-child > button')!
|
|
376
|
+
const prevButton = base.querySelector<HTMLButtonElement>('.au-datepicker__header > button:first-child')!
|
|
377
|
+
const nextButton = base.querySelector<HTMLButtonElement>('.au-datepicker__header > button:last-child')!
|
|
378
|
+
const segments = Array.from(base.querySelectorAll<HTMLElement>('.au-datepicker__segment'))
|
|
379
|
+
const cancelButton = calendarEl.querySelector<HTMLButtonElement>('.au-datepicker__calendar-cancel')!
|
|
380
|
+
const confirmButton = calendarEl.querySelector<HTMLButtonElement>('.au-datepicker__calendar-actions-dock > button:last-child')!
|
|
381
|
+
const backdrop = calendarEl.querySelector<HTMLElement>('.au-datepicker__calendar-backdrop')!
|
|
382
|
+
|
|
383
|
+
// react-stately calendar state.
|
|
384
|
+
let value: DateShape | null = selectedDate
|
|
385
|
+
let focusedDate: DateShape = constrain(value ?? today())
|
|
386
|
+
let startDate: DateShape = startOfMonth(focusedDate)
|
|
387
|
+
let isFocused = true // `autoFocus`
|
|
388
|
+
let mounted = false
|
|
389
|
+
let enterTimer: number | undefined
|
|
390
|
+
let leaveTimer: number | undefined
|
|
391
|
+
let lastRange = ''
|
|
392
|
+
let lastSelectedDescription = ''
|
|
393
|
+
let showing = false
|
|
394
|
+
let lastFocusKey = ''
|
|
395
|
+
const portalDoor = document.createElement('div')
|
|
396
|
+
|
|
397
|
+
function constrain(date: DateShape) {
|
|
398
|
+
if (compare(date, minValue) < 0) return minValue
|
|
399
|
+
if (compare(date, maxValue) > 0) return maxValue
|
|
400
|
+
return date
|
|
401
|
+
}
|
|
402
|
+
const isInvalid = (date: DateShape) => compare(date, minValue) < 0 || compare(date, maxValue) > 0
|
|
403
|
+
const endDate = () => endOfMonth(startDate)
|
|
404
|
+
const isCellDisabled = (date: DateShape) => compare(date, startDate) < 0 || compare(date, endDate()) > 0 || isInvalid(date)
|
|
405
|
+
const isSelected = (date: DateShape) => value !== null && isSameDay(date, value) && !isCellDisabled(date)
|
|
406
|
+
const rangeDescription = () => monthFormatter.format(toNative(startDate))
|
|
407
|
+
const selectedDescription = () => (value ? t('selectedDateDescription', dayLabelFormatter.format(toNative(value))) : '')
|
|
408
|
+
|
|
409
|
+
function setFocusedDate(date: DateShape) {
|
|
410
|
+
focusedDate = constrain(date)
|
|
411
|
+
if (compare(focusedDate, startDate) < 0 || compare(focusedDate, endDate()) > 0) startDate = startOfMonth(focusedDate)
|
|
412
|
+
render()
|
|
413
|
+
}
|
|
414
|
+
// `focusNextPage` / `focusPreviousPage`, one month per page.
|
|
415
|
+
function focusPage(direction: 1 | -1) {
|
|
416
|
+
focusedDate = constrain(addMonths(focusedDate, direction))
|
|
417
|
+
startDate = startOfMonth(constrain(addMonths(startDate, direction)))
|
|
418
|
+
render()
|
|
419
|
+
}
|
|
420
|
+
function setValue(next: DateShape | null) {
|
|
421
|
+
value = next
|
|
422
|
+
if (next) focusedDate = constrain(next)
|
|
423
|
+
if (compare(focusedDate, startDate) < 0 || compare(focusedDate, endDate()) > 0) startDate = startOfMonth(focusedDate)
|
|
424
|
+
render()
|
|
425
|
+
}
|
|
426
|
+
function selectDate(date: DateShape, byPointer = false) {
|
|
427
|
+
// `calendarChange`: on desktop the pick is final and closes; on mobile
|
|
428
|
+
// it only moves the selection until "Confirmar".
|
|
429
|
+
value = constrain(date)
|
|
430
|
+
focusedDate = value
|
|
431
|
+
if (!byPointer) isFocused = true
|
|
432
|
+
render()
|
|
433
|
+
if (!above(BREAKPOINT_MD)) return
|
|
434
|
+
triggerChange(value)
|
|
435
|
+
}
|
|
436
|
+
function triggerChange(date: DateShape) {
|
|
437
|
+
updateDateFromCalendar(date)
|
|
438
|
+
closeCalendar()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---- rendering ---------------------------------------------------------
|
|
442
|
+
function weekdays() {
|
|
443
|
+
const start = addDays(today(), -((toNative(today()).getDay() - weekStart + 7) % 7))
|
|
444
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
445
|
+
const day = weekdayFormatter.format(toNative(addDays(start, i)))
|
|
446
|
+
// `fmtWeekday` in `useCalendar`.
|
|
447
|
+
return `${day.charAt(0).toUpperCase()}${day.slice(1)}`.replace('.', '')
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
function datesInWeek(weekIndex: number): DateShape[] {
|
|
451
|
+
const from = addDays(startDate, weekIndex * 7)
|
|
452
|
+
const offset = (toNative(from).getDay() - weekStart + 7) % 7
|
|
453
|
+
const first = addDays(from, -offset)
|
|
454
|
+
return Array.from({ length: 7 }, (_, i) => addDays(first, i))
|
|
455
|
+
}
|
|
456
|
+
function weeksInMonth() {
|
|
457
|
+
const offset = (toNative(startDate).getDay() - weekStart + 7) % 7
|
|
458
|
+
return Math.ceil((offset + daysInMonth(startDate.year, startDate.month)) / 7)
|
|
459
|
+
}
|
|
460
|
+
function cellLabel(date: DateShape) {
|
|
461
|
+
let label = dayLabelFormatter.format(toNative(date))
|
|
462
|
+
const selected = isSelected(date)
|
|
463
|
+
if (isSameDay(date, today())) label = t(selected ? 'todayDateSelected' : 'todayDate', label)
|
|
464
|
+
else if (selected) label = t('dateSelected', label)
|
|
465
|
+
if (isSameDay(date, minValue)) label += `, ${t('minimumDate')}`
|
|
466
|
+
else if (isSameDay(date, maxValue)) label += `, ${t('maximumDate')}`
|
|
467
|
+
return label
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const cells = new Map<string, HTMLElement>()
|
|
471
|
+
const segmentResets: Array<() => void> = []
|
|
472
|
+
function renderGrid() {
|
|
473
|
+
const weeks = weeksInMonth()
|
|
474
|
+
const rows = Array.from(body.children) as HTMLTableRowElement[]
|
|
475
|
+
while (rows.length > weeks) rows.pop()!.remove()
|
|
476
|
+
cells.clear()
|
|
477
|
+
for (let w = 0; w < weeks; w++) {
|
|
478
|
+
let row = rows[w]
|
|
479
|
+
if (!row) {
|
|
480
|
+
row = document.createElement('tr')
|
|
481
|
+
body.appendChild(row)
|
|
482
|
+
}
|
|
483
|
+
const dates = datesInWeek(w)
|
|
484
|
+
const tds = Array.from(row.children) as HTMLTableCellElement[]
|
|
485
|
+
while (tds.length > 7) tds.pop()!.remove()
|
|
486
|
+
dates.forEach((date, i) => {
|
|
487
|
+
let td = tds[i]
|
|
488
|
+
if (!td) {
|
|
489
|
+
td = document.createElement('td')
|
|
490
|
+
td.setAttribute('role', 'gridcell')
|
|
491
|
+
td.appendChild(createCell())
|
|
492
|
+
row!.appendChild(td)
|
|
493
|
+
}
|
|
494
|
+
renderCell(td, date)
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function createCell() {
|
|
499
|
+
const cell = document.createElement('div')
|
|
500
|
+
cell.dataset.reactAriaPressable = 'true'
|
|
501
|
+
cell.setAttribute('role', 'button')
|
|
502
|
+
cell.className = 'au-datepicker__calendar-day'
|
|
503
|
+
cell.dataset.rac = ''
|
|
504
|
+
// useHover (mouse only) and usePress with `preventFocusOnPress`: the
|
|
505
|
+
// press never moves focus, the pick happens on release.
|
|
506
|
+
let pressing = false
|
|
507
|
+
cell.addEventListener('pointerenter', (e) => { if (e.pointerType !== 'touch' && !cell.hasAttribute('aria-disabled')) cell.dataset.hovered = 'true' })
|
|
508
|
+
cell.addEventListener('pointerleave', () => { delete cell.dataset.hovered; pressing = false })
|
|
509
|
+
cell.addEventListener('mousedown', (e) => e.preventDefault())
|
|
510
|
+
cell.addEventListener('pointerdown', () => { pressing = !cell.hasAttribute('aria-disabled') })
|
|
511
|
+
cell.addEventListener('pointerup', () => {
|
|
512
|
+
if (!pressing) return
|
|
513
|
+
pressing = false
|
|
514
|
+
const date = cellDate(cell)
|
|
515
|
+
if (date) selectDate(date, true)
|
|
516
|
+
})
|
|
517
|
+
cell.addEventListener('pointercancel', () => { pressing = false })
|
|
518
|
+
cell.addEventListener('focus', () => {
|
|
519
|
+
const date = cellDate(cell)
|
|
520
|
+
if (date && !cell.hasAttribute('aria-disabled')) {
|
|
521
|
+
focusedDate = date
|
|
522
|
+
isFocused = true
|
|
523
|
+
render()
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
cell.addEventListener('contextmenu', (e) => e.preventDefault())
|
|
527
|
+
return cell
|
|
528
|
+
}
|
|
529
|
+
const cellDate = (cell: HTMLElement) => (cell.dataset.auDate ? parseIso(cell.dataset.auDate) : null)
|
|
530
|
+
function renderCell(td: HTMLTableCellElement, date: DateShape) {
|
|
531
|
+
const cell = td.firstElementChild as HTMLElement
|
|
532
|
+
const disabledCell = isCellDisabled(date)
|
|
533
|
+
const selected = isSelected(date)
|
|
534
|
+
const outsideMonth = date.month !== startDate.month || date.year !== startDate.year
|
|
535
|
+
const focusedCell = isFocused && isSameDay(date, focusedDate) && !outsideMonth
|
|
536
|
+
const iso = `${date.year}-${pad(date.month)}-${pad(date.day)}`
|
|
537
|
+
cell.dataset.auDate = iso
|
|
538
|
+
cells.set(iso, cell)
|
|
539
|
+
|
|
540
|
+
setAttr(td, 'aria-disabled', disabledCell ? 'true' : null)
|
|
541
|
+
setAttr(td, 'aria-selected', selected ? 'true' : null)
|
|
542
|
+
|
|
543
|
+
setAttr(cell, 'tabindex', disabledCell ? null : isSameDay(date, focusedDate) ? '0' : '-1')
|
|
544
|
+
setAttr(cell, 'aria-disabled', disabledCell ? 'true' : null)
|
|
545
|
+
setAttr(cell, 'aria-label', cellLabel(date))
|
|
546
|
+
setAttr(cell, 'data-focused', focusedCell ? 'true' : null)
|
|
547
|
+
setAttr(cell, 'data-disabled', disabledCell ? 'true' : null)
|
|
548
|
+
setAttr(cell, 'data-focus-visible', focusedCell && modality === 'keyboard' && document.activeElement === cell ? 'true' : null)
|
|
549
|
+
setAttr(cell, 'data-outside-visible-range', outsideMonth ? 'true' : null)
|
|
550
|
+
setAttr(cell, 'data-outside-month', outsideMonth ? 'true' : null)
|
|
551
|
+
setAttr(cell, 'data-selected', selected ? 'true' : null)
|
|
552
|
+
setAttr(cell, 'data-today', isSameDay(date, today()) ? 'true' : null)
|
|
553
|
+
const text = dayNumberFormatter.formatToParts(toNative(date)).find((p) => p.type === 'day')?.value ?? String(date.day)
|
|
554
|
+
if (cell.textContent !== text) cell.textContent = text
|
|
555
|
+
}
|
|
556
|
+
function setAttr(el: Element, name: string, val: string | null) {
|
|
557
|
+
if (val === null) el.removeAttribute(name)
|
|
558
|
+
else if (el.getAttribute(name) !== val) el.setAttribute(name, val)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function renderSegments() {
|
|
562
|
+
segments.forEach((segment, index) => {
|
|
563
|
+
const current = index === 0 ? focusedDate.month : focusedDate.year
|
|
564
|
+
const button = segment.querySelector<HTMLButtonElement>('.au-datepicker__segment-input')!
|
|
565
|
+
const items = Array.from(segment.querySelectorAll<HTMLLIElement>('.au-datepicker__segment-list-item'))
|
|
566
|
+
let label = ''
|
|
567
|
+
items.forEach((item) => {
|
|
568
|
+
const active = Number(item.dataset.auValue) === current
|
|
569
|
+
item.classList.toggle('au-datepicker__segment-list-item--active', active)
|
|
570
|
+
item.setAttribute('aria-selected', String(active))
|
|
571
|
+
if (active) label = item.textContent?.trim() ?? ''
|
|
572
|
+
})
|
|
573
|
+
if (button.firstChild?.nodeType === 3) {
|
|
574
|
+
if (button.firstChild.nodeValue !== label) button.firstChild.nodeValue = label
|
|
575
|
+
} else button.prepend(label)
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function render() {
|
|
580
|
+
const range = rangeDescription()
|
|
581
|
+
base.setAttribute('aria-label', range)
|
|
582
|
+
title.textContent = range
|
|
583
|
+
grid.setAttribute('aria-label', range)
|
|
584
|
+
hiddenNext.setAttribute('aria-label', t('next'))
|
|
585
|
+
hiddenNext.disabled = isInvalid(addDays(endDate(), 1))
|
|
586
|
+
weekdays().forEach((day, i) => { if (headerCells[i].textContent !== day) headerCells[i].textContent = day })
|
|
587
|
+
renderGrid()
|
|
588
|
+
renderSegments()
|
|
589
|
+
confirmButton.classList.toggle('au-btn--disabled', value === null)
|
|
590
|
+
confirmButton.disabled = value === null
|
|
591
|
+
|
|
592
|
+
// react-aria announces the visible range when it changes with the
|
|
593
|
+
// grid unfocused, and the selected date whenever it changes.
|
|
594
|
+
if (mounted && range !== lastRange && !isFocused) announce(range)
|
|
595
|
+
lastRange = range
|
|
596
|
+
const description = selectedDescription()
|
|
597
|
+
if (mounted && description !== lastSelectedDescription && description) announce(description, 'polite', 4000)
|
|
598
|
+
lastSelectedDescription = description
|
|
599
|
+
|
|
600
|
+
// The react-aria cell effect: when a cell becomes the focused one
|
|
601
|
+
// while the calendar is focused, it takes DOM focus wherever it was
|
|
602
|
+
// (the field included), except on mount, where the field's own focus
|
|
603
|
+
// wins.
|
|
604
|
+
const focusKey = isFocused ? `${focusedDate.year}-${pad(focusedDate.month)}-${pad(focusedDate.day)}` : ''
|
|
605
|
+
const focused = focusKey ? cells.get(focusKey) : undefined
|
|
606
|
+
if (focused && focusKey !== lastFocusKey && !showing && document.activeElement !== focused) {
|
|
607
|
+
focused.focus({ preventScroll: true })
|
|
608
|
+
setAttr(focused, 'data-focus-visible', modality === 'keyboard' ? 'true' : null)
|
|
609
|
+
}
|
|
610
|
+
lastFocusKey = focusKey
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ---- keyboard (useCalendarGrid) ----------------------------------------
|
|
614
|
+
grid.addEventListener('keydown', (event) => {
|
|
615
|
+
const stop = () => { event.preventDefault(); event.stopPropagation() }
|
|
616
|
+
switch (event.key) {
|
|
617
|
+
case 'Enter':
|
|
618
|
+
case ' ':
|
|
619
|
+
event.preventDefault()
|
|
620
|
+
selectDate(focusedDate)
|
|
621
|
+
break
|
|
622
|
+
case 'PageUp':
|
|
623
|
+
stop()
|
|
624
|
+
if (event.shiftKey) setFocusedDate(addYears(focusedDate, -1))
|
|
625
|
+
else setFocusedDate(addMonths(focusedDate, -1))
|
|
626
|
+
break
|
|
627
|
+
case 'PageDown':
|
|
628
|
+
stop()
|
|
629
|
+
if (event.shiftKey) setFocusedDate(addYears(focusedDate, 1))
|
|
630
|
+
else setFocusedDate(addMonths(focusedDate, 1))
|
|
631
|
+
break
|
|
632
|
+
case 'End':
|
|
633
|
+
stop()
|
|
634
|
+
setFocusedDate(endOfMonth(focusedDate))
|
|
635
|
+
break
|
|
636
|
+
case 'Home':
|
|
637
|
+
stop()
|
|
638
|
+
setFocusedDate(startOfMonth(focusedDate))
|
|
639
|
+
break
|
|
640
|
+
case 'ArrowLeft':
|
|
641
|
+
stop()
|
|
642
|
+
setFocusedDate(addDays(focusedDate, -1))
|
|
643
|
+
break
|
|
644
|
+
case 'ArrowUp':
|
|
645
|
+
stop()
|
|
646
|
+
setFocusedDate(addDays(focusedDate, -7))
|
|
647
|
+
break
|
|
648
|
+
case 'ArrowRight':
|
|
649
|
+
stop()
|
|
650
|
+
setFocusedDate(addDays(focusedDate, 1))
|
|
651
|
+
break
|
|
652
|
+
case 'ArrowDown':
|
|
653
|
+
stop()
|
|
654
|
+
setFocusedDate(addDays(focusedDate, 7))
|
|
655
|
+
break
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
grid.addEventListener('focusin', () => { isFocused = true; render() })
|
|
659
|
+
grid.addEventListener('focusout', () => { isFocused = false; render() })
|
|
660
|
+
|
|
661
|
+
// ---- header (useCalendarHeader + useSegment) ---------------------------
|
|
662
|
+
prevButton.addEventListener('click', () => focusPage(-1))
|
|
663
|
+
nextButton.addEventListener('click', () => focusPage(1))
|
|
664
|
+
segments.forEach((segment, index) => {
|
|
665
|
+
const button = segment.querySelector<HTMLButtonElement>('.au-datepicker__segment-input')!
|
|
666
|
+
const list = segment.querySelector<HTMLElement>('.au-datepicker__segment-list')!
|
|
667
|
+
let open = false
|
|
668
|
+
const setOpen = (next: boolean) => {
|
|
669
|
+
open = next
|
|
670
|
+
segment.classList.toggle('au-datepicker__segment--open', open)
|
|
671
|
+
button.setAttribute('aria-expanded', String(open))
|
|
672
|
+
if (open) list.querySelector<HTMLElement>('.au-datepicker__segment-list-item--active')?.scrollIntoView({ block: 'center', inline: 'center' })
|
|
673
|
+
}
|
|
674
|
+
const outside = (event: MouseEvent) => {
|
|
675
|
+
if (segment.contains(event.target as Node)) return
|
|
676
|
+
setOpen(false)
|
|
677
|
+
document.removeEventListener('mousedown', outside)
|
|
678
|
+
}
|
|
679
|
+
segmentResets.push(() => { if (open) setOpen(false); document.removeEventListener('mousedown', outside) })
|
|
680
|
+
button.addEventListener('click', () => {
|
|
681
|
+
setOpen(true)
|
|
682
|
+
if (above(BREAKPOINT_MD)) document.addEventListener('mousedown', outside)
|
|
683
|
+
})
|
|
684
|
+
segment.querySelectorAll<HTMLButtonElement>('.au-datepicker__segment-list-header button').forEach((close) => close.addEventListener('click', () => setOpen(false)))
|
|
685
|
+
list.addEventListener('click', (event) => {
|
|
686
|
+
const item = (event.target as Element | null)?.closest<HTMLElement>('.au-datepicker__segment-list-item')
|
|
687
|
+
if (!item) return
|
|
688
|
+
const picked = Number(item.dataset.auValue)
|
|
689
|
+
const next = index === 0
|
|
690
|
+
? { year: focusedDate.year, month: picked, day: Math.min(focusedDate.day, daysInMonth(focusedDate.year, picked)) }
|
|
691
|
+
: { year: picked, month: focusedDate.month, day: Math.min(focusedDate.day, daysInMonth(picked, focusedDate.month)) }
|
|
692
|
+
setFocusedDate(next)
|
|
693
|
+
setOpen(false)
|
|
694
|
+
})
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
cancelButton.addEventListener('click', closeCalendar)
|
|
698
|
+
confirmButton.addEventListener('click', () => { if (value) triggerChange(value) })
|
|
699
|
+
backdrop.addEventListener('click', closeCalendar)
|
|
700
|
+
|
|
701
|
+
// ---- mount / unmount (useCalendar delays + PortalHolder) ---------------
|
|
702
|
+
function mount() {
|
|
703
|
+
if (mounted) return
|
|
704
|
+
mounted = true
|
|
705
|
+
if (!above(BREAKPOINT_MD) && withPortal) {
|
|
706
|
+
let portalRoot = document.getElementById('au-portal')
|
|
707
|
+
if (!portalRoot) {
|
|
708
|
+
portalRoot = document.createElement('div')
|
|
709
|
+
portalRoot.id = 'au-portal'
|
|
710
|
+
document.body.appendChild(portalRoot)
|
|
711
|
+
}
|
|
712
|
+
portalRoot.appendChild(portalDoor)
|
|
713
|
+
portalDoor.appendChild(calendarEl)
|
|
714
|
+
} else {
|
|
715
|
+
root.appendChild(calendarEl)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function unmount() {
|
|
719
|
+
if (!mounted) return
|
|
720
|
+
mounted = false
|
|
721
|
+
calendarEl.remove()
|
|
722
|
+
portalDoor.remove()
|
|
723
|
+
cells.clear()
|
|
724
|
+
// React mounts a fresh calendar every time: no hover left over and
|
|
725
|
+
// every picker closed.
|
|
726
|
+
body.querySelectorAll<HTMLElement>('[data-hovered]').forEach((cell) => { delete cell.dataset.hovered })
|
|
727
|
+
segmentResets.forEach((reset) => reset())
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
show() {
|
|
732
|
+
window.clearTimeout(leaveTimer)
|
|
733
|
+
// `defaultFocusDate` → `setFocusedDate` on the header effect.
|
|
734
|
+
if (value) { focusedDate = constrain(value); startDate = startOfMonth(focusedDate) }
|
|
735
|
+
isFocused = true
|
|
736
|
+
lastRange = rangeDescription()
|
|
737
|
+
lastSelectedDescription = selectedDescription()
|
|
738
|
+
showing = true
|
|
739
|
+
render()
|
|
740
|
+
showing = false
|
|
741
|
+
lastFocusKey = `${focusedDate.year}-${pad(focusedDate.month)}-${pad(focusedDate.day)}`
|
|
742
|
+
mount()
|
|
743
|
+
enterTimer = window.setTimeout(() => calendarEl.classList.add('au-datepicker__calendar--visible'), 100)
|
|
744
|
+
},
|
|
745
|
+
hide() {
|
|
746
|
+
window.clearTimeout(enterTimer)
|
|
747
|
+
calendarEl.classList.remove('au-datepicker__calendar--visible')
|
|
748
|
+
leaveTimer = window.setTimeout(unmount, 200)
|
|
749
|
+
},
|
|
750
|
+
setValue(next) {
|
|
751
|
+
setValue(next)
|
|
752
|
+
},
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
</script>
|