@datatechsolutions/tympan 0.3.1 → 0.4.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/dist/{Avatar-P14MVkK1.js → Avatar-CU7B1WsX.js} +61 -1
- package/dist/{Avatar-P14MVkK1.js.map → Avatar-CU7B1WsX.js.map} +1 -1
- package/dist/avatars.js +1 -1
- package/dist/flow.d.ts +1 -1
- package/dist/flow.js +2 -2
- package/dist/{index-6wR5ThnX.d.ts → index-J-7EBkCL.d.ts} +172 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -3
- package/dist/{src-C9whlpC7.js → src-uCyIE-Te.js} +909 -379
- package/dist/src-uCyIE-Te.js.map +1 -0
- package/dist/styles.css +476 -0
- package/package.json +2 -2
- package/src/components/calendar/Calendar.css +260 -0
- package/src/components/calendar/Calendar.test.tsx +178 -0
- package/src/components/calendar/Calendar.tsx +341 -0
- package/src/components/date-range-field/DateRangeField.css +140 -0
- package/src/components/date-range-field/DateRangeField.test.tsx +187 -0
- package/src/components/date-range-field/DateRangeField.tsx +231 -0
- package/src/components/tooltip/Tooltip.css +70 -0
- package/src/components/tooltip/Tooltip.test.tsx +209 -0
- package/src/components/tooltip/Tooltip.tsx +102 -0
- package/src/index.ts +3 -0
- package/src/internal/messages/core-es.ts +17 -0
- package/src/internal/messages.ts +51 -0
- package/src/styles.css +3 -0
- package/dist/src-C9whlpC7.js.map +0 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { CalendarDate, today as todayIn, getLocalTimeZone } from '@internationalized/date'
|
|
2
|
+
import { CalendarDays } from 'lucide-react'
|
|
3
|
+
import { useId, useState, type ReactNode } from 'react'
|
|
4
|
+
import { Button as AriaButton, DateInput, DateRangePicker, DateSegment, Group, I18nProvider, useLocale } from 'react-aria-components'
|
|
5
|
+
import { cx } from '../../internal/cx'
|
|
6
|
+
import { FieldLine, FloatSurface, joinIds, usePhoneWidth } from '../../internal/forms-b/parts'
|
|
7
|
+
import { useMessages } from '../../internal/provider'
|
|
8
|
+
import { Button } from '../button/Button'
|
|
9
|
+
import { Calendar } from '../calendar/Calendar'
|
|
10
|
+
|
|
11
|
+
/** A start-to-end range of calendar dates without time, exchanged as local `Date`s. */
|
|
12
|
+
export interface DateRangeFieldValue {
|
|
13
|
+
start: Date | null
|
|
14
|
+
end: Date | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One quick choice in the popover: `{ id, label, range }`. */
|
|
18
|
+
export interface DateRangePreset {
|
|
19
|
+
id: string
|
|
20
|
+
label: string
|
|
21
|
+
range: DateRangeFieldValue
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DateRangeFieldProps {
|
|
25
|
+
label: string
|
|
26
|
+
value: DateRangeFieldValue | null
|
|
27
|
+
defaultValue?: DateRangeFieldValue | null
|
|
28
|
+
onChange: (value: DateRangeFieldValue | null) => void
|
|
29
|
+
minValue?: Date
|
|
30
|
+
maxValue?: Date
|
|
31
|
+
isDateUnavailable?: (date: Date) => boolean
|
|
32
|
+
/** Longest allowed range in days; longer ranges are invalid. */
|
|
33
|
+
maxDays?: number
|
|
34
|
+
/** Quick choices shown beside or above the calendar. */
|
|
35
|
+
presets?: DateRangePreset[]
|
|
36
|
+
/** Immediate: the second pick commits and closes. Apply: picks are a draft until Apply. */
|
|
37
|
+
confirmation?: 'immediate' | 'apply'
|
|
38
|
+
/** Months in the popover (one on narrow screens whatever the value). */
|
|
39
|
+
visibleMonths?: 1 | 2
|
|
40
|
+
/** Form field names; both dates submit in ISO format. */
|
|
41
|
+
startName?: string
|
|
42
|
+
endName?: string
|
|
43
|
+
required?: boolean
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
readOnly?: boolean
|
|
46
|
+
errorText?: string
|
|
47
|
+
hint?: ReactNode
|
|
48
|
+
/** Overrides the locale from the provider. */
|
|
49
|
+
locale?: string
|
|
50
|
+
className?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fromDate = (d: Date | null) => (d ? new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate()) : null)
|
|
54
|
+
const toDate = (c: CalendarDate | null) => (c ? new Date(c.year, c.month - 1, c.day) : null)
|
|
55
|
+
const daysBetween = (a: Date, b: Date) => Math.round((b.getTime() - a.getTime()) / 86400000)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Enter a start and end date by typing into segmented date inputs or by
|
|
59
|
+
* picking in a Calendar popover (spec: wave-5-general/date-range-field.md).
|
|
60
|
+
* Built on RAC DateRangePicker; APG Date Picker Dialog.
|
|
61
|
+
*/
|
|
62
|
+
export function DateRangeField(props: DateRangeFieldProps) {
|
|
63
|
+
const t = useMessages().dateRangeField
|
|
64
|
+
const { locale: contextLocale } = useLocale()
|
|
65
|
+
const now = todayIn(getLocalTimeZone())
|
|
66
|
+
|
|
67
|
+
const [innerValue, setInnerValue] = useState<DateRangeFieldValue | null>(() => props.value ?? props.defaultValue ?? null)
|
|
68
|
+
const value = props.value !== undefined ? props.value : innerValue
|
|
69
|
+
const [open, setOpen] = useState(false)
|
|
70
|
+
const [draft, setDraft] = useState<DateRangeFieldValue | null>(null)
|
|
71
|
+
const phone = usePhoneWidth()
|
|
72
|
+
const months: 1 | 2 = phone ? 1 : (props.visibleMonths ?? 2)
|
|
73
|
+
const confirmation = props.confirmation ?? 'immediate'
|
|
74
|
+
|
|
75
|
+
const commit = (next: DateRangeFieldValue | null) => {
|
|
76
|
+
if (props.value === undefined) setInnerValue(next)
|
|
77
|
+
props.onChange(next)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const shown = open && confirmation === 'apply' ? draft : value
|
|
81
|
+
const shownRange = shown && (shown.start || shown.end) ? { start: fromDate(shown.start), end: fromDate(shown.end) } : null
|
|
82
|
+
const racValue = shownRange && shownRange.start && shownRange.end ? { start: shownRange.start, end: shownRange.end } : null
|
|
83
|
+
|
|
84
|
+
// Validation: the host text wins, then the computed reasons.
|
|
85
|
+
const computedInvalid = (() => {
|
|
86
|
+
const start = value?.start
|
|
87
|
+
const end = value?.end
|
|
88
|
+
if (!start || !end) return null
|
|
89
|
+
if (end.getTime() < start.getTime()) return t.endBeforeStart
|
|
90
|
+
if (props.maxDays != null && daysBetween(start, end) + 1 > props.maxDays) return t.tooManyDays(props.maxDays)
|
|
91
|
+
if (props.minValue && start.getTime() < props.minValue.getTime()) return t.outOfBounds
|
|
92
|
+
if (props.maxValue && end.getTime() > props.maxValue.getTime()) return t.outOfBounds
|
|
93
|
+
if (props.isDateUnavailable) {
|
|
94
|
+
const cursor = new Date(start.getTime())
|
|
95
|
+
while (cursor.getTime() <= end.getTime()) {
|
|
96
|
+
if (props.isDateUnavailable(cursor)) return t.unavailableDay
|
|
97
|
+
cursor.setDate(cursor.getDate() + 1)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
})()
|
|
102
|
+
const errorMessage = props.errorText ?? computedInvalid ?? undefined
|
|
103
|
+
const invalid = !!errorMessage
|
|
104
|
+
|
|
105
|
+
const base = useId()
|
|
106
|
+
const labelId = `${base}-label`
|
|
107
|
+
const hintId = props.hint != null && !errorMessage ? `${base}-hint` : undefined
|
|
108
|
+
const errorId = errorMessage ? `${base}-error` : undefined
|
|
109
|
+
|
|
110
|
+
const openChange = (next: boolean) => {
|
|
111
|
+
if (next) setDraft(value ? { ...value } : null)
|
|
112
|
+
setOpen(next)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const pickFromCalendar = (range: { start: Date; end: Date } | null) => {
|
|
116
|
+
if (confirmation === 'apply') {
|
|
117
|
+
setDraft(range)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
commit(range)
|
|
121
|
+
setOpen(false)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const typed = (range: { start: CalendarDate | null; end: CalendarDate | null } | null) => {
|
|
125
|
+
const next: DateRangeFieldValue | null = range && (range.start || range.end) ? { start: toDate(range.start), end: toDate(range.end) } : null
|
|
126
|
+
if (open && confirmation === 'apply') setDraft(next)
|
|
127
|
+
else commit(next)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const applyPreset = (preset: DateRangePreset) => {
|
|
131
|
+
if (confirmation === 'apply') {
|
|
132
|
+
setDraft({ ...preset.range })
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
commit({ ...preset.range })
|
|
136
|
+
setOpen(false)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const calendar = (
|
|
140
|
+
<Calendar
|
|
141
|
+
label={props.label}
|
|
142
|
+
mode="range"
|
|
143
|
+
value={draft && draft.start && draft.end ? { start: draft.start, end: draft.end } : null}
|
|
144
|
+
onChange={(next) => pickFromCalendar(next as { start: Date; end: Date } | null)}
|
|
145
|
+
minValue={props.minValue}
|
|
146
|
+
maxValue={props.maxValue}
|
|
147
|
+
isDateUnavailable={props.isDateUnavailable}
|
|
148
|
+
visibleMonths={months}
|
|
149
|
+
focusedValue={draft?.start ?? (value?.start ?? new Date(now.year, now.month - 1, now.day))}
|
|
150
|
+
isInvalid={invalid}
|
|
151
|
+
/>
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<I18nProvider locale={props.locale ?? contextLocale}>
|
|
156
|
+
<div className={cx('ty-date-range-field', props.className)} data-invalid={invalid || undefined}>
|
|
157
|
+
<FieldLine kind="label" id={labelId}>
|
|
158
|
+
<span>{props.label}</span>
|
|
159
|
+
</FieldLine>
|
|
160
|
+
{hintId ? (
|
|
161
|
+
<FieldLine kind="hint" id={hintId}>
|
|
162
|
+
{props.hint}
|
|
163
|
+
</FieldLine>
|
|
164
|
+
) : null}
|
|
165
|
+
<DateRangePicker
|
|
166
|
+
className="ty-date-range-field__picker"
|
|
167
|
+
value={racValue}
|
|
168
|
+
onChange={typed}
|
|
169
|
+
isOpen={open}
|
|
170
|
+
onOpenChange={openChange}
|
|
171
|
+
shouldCloseOnSelect={false}
|
|
172
|
+
startName={props.startName}
|
|
173
|
+
endName={props.endName}
|
|
174
|
+
minValue={fromDate(props.minValue ?? null) ?? undefined}
|
|
175
|
+
maxValue={fromDate(props.maxValue ?? null) ?? undefined}
|
|
176
|
+
isDateUnavailable={(d) => (props.isDateUnavailable ? props.isDateUnavailable(toDate(d as CalendarDate)!) : false)}
|
|
177
|
+
isDisabled={props.disabled}
|
|
178
|
+
isReadOnly={props.readOnly}
|
|
179
|
+
isRequired={props.required}
|
|
180
|
+
isInvalid={invalid || undefined}
|
|
181
|
+
aria-labelledby={labelId}
|
|
182
|
+
aria-describedby={joinIds(hintId, errorId)}
|
|
183
|
+
>
|
|
184
|
+
<Group className="ty-date-range-field__group">
|
|
185
|
+
<DateInput slot="start" className="ty-date-range-field__input" aria-label={`${props.label}: ${t.start}`}>
|
|
186
|
+
{(segment) => <DateSegment segment={segment} className="ty-date-range-field__segment" />}
|
|
187
|
+
</DateInput>
|
|
188
|
+
<span className="ty-date-range-field__dash" aria-hidden="true">
|
|
189
|
+
–
|
|
190
|
+
</span>
|
|
191
|
+
<DateInput slot="end" className="ty-date-range-field__input" aria-label={`${props.label}: ${t.end}`}>
|
|
192
|
+
{(segment) => <DateSegment segment={segment} className="ty-date-range-field__segment" />}
|
|
193
|
+
</DateInput>
|
|
194
|
+
<AriaButton className="ty-date-range-field__trigger" aria-label={props.label}>
|
|
195
|
+
<CalendarDays className="ty-icon" aria-hidden="true" focusable="false" />
|
|
196
|
+
</AriaButton>
|
|
197
|
+
</Group>
|
|
198
|
+
<FloatSurface labelledBy={labelId} className="ty-date-range-field__surface">
|
|
199
|
+
<div className="ty-date-range-field__panel">
|
|
200
|
+
{props.presets?.length ? (
|
|
201
|
+
<div className="ty-date-range-field__presets" role="group" aria-label={t.presets}>
|
|
202
|
+
{props.presets.map((preset) => (
|
|
203
|
+
<Button key={preset.id} variant="quiet" size="compact" onPress={() => applyPreset(preset)}>
|
|
204
|
+
{preset.label}
|
|
205
|
+
</Button>
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
208
|
+
) : null}
|
|
209
|
+
{calendar}
|
|
210
|
+
{confirmation === 'apply' ? (
|
|
211
|
+
<div className="ty-date-range-field__actions">
|
|
212
|
+
<Button variant="quiet" size="compact" onPress={() => openChange(false)}>
|
|
213
|
+
{t.cancel}
|
|
214
|
+
</Button>
|
|
215
|
+
<Button variant="primary" size="compact" onPress={() => { commit(draft); setOpen(false) }}>
|
|
216
|
+
{t.apply}
|
|
217
|
+
</Button>
|
|
218
|
+
</div>
|
|
219
|
+
) : null}
|
|
220
|
+
</div>
|
|
221
|
+
</FloatSurface>
|
|
222
|
+
</DateRangePicker>
|
|
223
|
+
{errorId ? (
|
|
224
|
+
<FieldLine kind="error" id={errorId}>
|
|
225
|
+
{errorMessage}
|
|
226
|
+
</FieldLine>
|
|
227
|
+
) : null}
|
|
228
|
+
</div>
|
|
229
|
+
</I18nProvider>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* Tooltip (spec: wave-5-general/tooltip.md; design direction §2.5, §2.7). */
|
|
2
|
+
@layer tympan.components {
|
|
3
|
+
.ty-tooltip {
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
max-inline-size: min(36ch, calc(100vw - 2 * var(--ty-space-4)));
|
|
6
|
+
padding-block: var(--ty-space-1) var(--ty-space-1);
|
|
7
|
+
padding-inline: var(--ty-space-2) var(--ty-space-3);
|
|
8
|
+
border: 1px solid var(--ty-line);
|
|
9
|
+
border-radius: var(--ty-radius-control);
|
|
10
|
+
background: var(--ty-surface-raised-solid);
|
|
11
|
+
color: var(--ty-ink);
|
|
12
|
+
font-family: var(--ty-font-sans);
|
|
13
|
+
font-size: var(--ty-font-size-meta);
|
|
14
|
+
line-height: var(--ty-font-line-height-meta);
|
|
15
|
+
box-shadow: var(--ty-shadow-floating);
|
|
16
|
+
z-index: var(--ty-z-popover);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Entrance and exit use opacity only (WCAG 1.4.13: hoverable, persistent). */
|
|
20
|
+
.ty-tooltip[data-entering] {
|
|
21
|
+
animation: ty-tooltip-fade var(--ty-dur-quick) var(--ty-ease);
|
|
22
|
+
}
|
|
23
|
+
.ty-tooltip[data-exiting] {
|
|
24
|
+
animation: ty-tooltip-fade var(--ty-dur-quick) var(--ty-ease-out) reverse;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@keyframes ty-tooltip-fade {
|
|
28
|
+
from {
|
|
29
|
+
opacity: 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.ty-tooltip__content {
|
|
34
|
+
display: inline-block;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.ty-tooltip__arrow svg {
|
|
38
|
+
display: block;
|
|
39
|
+
fill: var(--ty-surface-raised-solid);
|
|
40
|
+
stroke: var(--ty-line);
|
|
41
|
+
stroke-width: 1px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@media (prefers-reduced-motion: reduce) {
|
|
45
|
+
.ty-tooltip[data-entering],
|
|
46
|
+
.ty-tooltip[data-exiting] {
|
|
47
|
+
animation: none;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
52
|
+
.ty-tooltip {
|
|
53
|
+
background: var(--ty-surface-raised-solid);
|
|
54
|
+
box-shadow: var(--ty-shadow-floating);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@media (forced-colors: active) {
|
|
59
|
+
.ty-tooltip {
|
|
60
|
+
border: 1px solid CanvasText;
|
|
61
|
+
background: Canvas;
|
|
62
|
+
color: CanvasText;
|
|
63
|
+
forced-color-adjust: none;
|
|
64
|
+
}
|
|
65
|
+
.ty-tooltip__arrow svg {
|
|
66
|
+
fill: Canvas;
|
|
67
|
+
stroke: CanvasText;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { Trash2 } from 'lucide-react'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { Button as RacButton } from 'react-aria-components'
|
|
6
|
+
import { ModalDialog } from '../modal-dialog/ModalDialog'
|
|
7
|
+
import { Tooltip } from './Tooltip'
|
|
8
|
+
|
|
9
|
+
import * as rtlDom from '@testing-library/react'
|
|
10
|
+
import { expectNoAxeViolations as axeRtl } from '../../../test/axe'
|
|
11
|
+
import { cssOf, mediaBlock } from '../../../test/css'
|
|
12
|
+
import { renderRtl } from '../../../test/rtl'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Icon-only trigger; `name` omitted is exactly the `role="label"` use case.
|
|
16
|
+
* A RAC Button (not a plain <button>): the trigger props arrive through
|
|
17
|
+
* FocusableContext and only RAC components consume them, and the rest spread
|
|
18
|
+
* forwards the cloned aria-labelledby of `role="label"`.
|
|
19
|
+
*/
|
|
20
|
+
function IconButton({ name, onPress, ...rest }: { name?: string; onPress?: () => void } & React.ComponentProps<typeof RacButton>) {
|
|
21
|
+
return (
|
|
22
|
+
<RacButton aria-label={name} onPress={onPress} {...rest}>
|
|
23
|
+
<Trash2 aria-hidden="true" focusable="false" />
|
|
24
|
+
</RacButton>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const rect = (x: number, y: number, w: number, h: number) => ({
|
|
29
|
+
value: () => ({ x, y, width: w, height: h, top: y, left: x, right: x + w, bottom: y + h, toJSON: () => ({}) }),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('Tooltip', () => {
|
|
33
|
+
it('opens on keyboard focus and describes the trigger', async () => {
|
|
34
|
+
render(
|
|
35
|
+
<Tooltip content="Delete">
|
|
36
|
+
<IconButton name="Delete row" />
|
|
37
|
+
</Tooltip>,
|
|
38
|
+
)
|
|
39
|
+
await userEvent.tab()
|
|
40
|
+
await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument())
|
|
41
|
+
expect(screen.getByRole('button', { name: 'Delete row' })).toHaveAccessibleDescription('Delete')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('names the trigger while closed when role is label', () => {
|
|
45
|
+
render(
|
|
46
|
+
<Tooltip content="Delete" role="label">
|
|
47
|
+
<IconButton />
|
|
48
|
+
</Tooltip>,
|
|
49
|
+
)
|
|
50
|
+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
|
51
|
+
expect(screen.getByRole('button')).toHaveAccessibleName('Delete')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('closes on Escape and keeps focus on the trigger', async () => {
|
|
55
|
+
render(
|
|
56
|
+
<Tooltip content="Delete">
|
|
57
|
+
<IconButton name="Delete row" />
|
|
58
|
+
</Tooltip>,
|
|
59
|
+
)
|
|
60
|
+
const trigger = screen.getByRole('button')
|
|
61
|
+
trigger.focus()
|
|
62
|
+
await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument())
|
|
63
|
+
await userEvent.keyboard('{Escape}')
|
|
64
|
+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
|
65
|
+
expect(trigger).toHaveFocus()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('Escape inside a dialog closes only the tooltip', async () => {
|
|
69
|
+
render(
|
|
70
|
+
<ModalDialog isOpen onOpenChange={() => {}} title="Confirm">
|
|
71
|
+
<Tooltip content="Delete">
|
|
72
|
+
<IconButton name="Delete row" />
|
|
73
|
+
</Tooltip>
|
|
74
|
+
</ModalDialog>,
|
|
75
|
+
)
|
|
76
|
+
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
|
77
|
+
const trigger = screen.getByRole('button', { name: 'Delete row' })
|
|
78
|
+
trigger.focus()
|
|
79
|
+
// The modal hides portaled siblings (aria-hide-outside), so the open
|
|
80
|
+
// tooltip only matches with hidden: true.
|
|
81
|
+
await waitFor(() => expect(screen.getByRole('tooltip', { hidden: true })).toBeInTheDocument())
|
|
82
|
+
await userEvent.keyboard('{Escape}')
|
|
83
|
+
expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument()
|
|
84
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('opens on hover and stays open while the pointer moves onto the bubble', async () => {
|
|
88
|
+
render(
|
|
89
|
+
<Tooltip content="Delete">
|
|
90
|
+
<IconButton name="Delete row" />
|
|
91
|
+
</Tooltip>,
|
|
92
|
+
)
|
|
93
|
+
const trigger = screen.getByRole('button')
|
|
94
|
+
Object.defineProperty(trigger, 'getBoundingClientRect', rect(8, 8, 32, 32))
|
|
95
|
+
// Prime the shared virtual pointer: the first hover of a session does not
|
|
96
|
+
// open a react-aria tooltip on its own.
|
|
97
|
+
await userEvent.hover(document.body)
|
|
98
|
+
await userEvent.hover(trigger)
|
|
99
|
+
await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument())
|
|
100
|
+
const bubble = screen.getByRole('tooltip')
|
|
101
|
+
Object.defineProperty(bubble, 'getBoundingClientRect', rect(8, -20, 60, 24))
|
|
102
|
+
await userEvent.hover(bubble)
|
|
103
|
+
await new Promise((r) => setTimeout(r, 60))
|
|
104
|
+
expect(bubble).toBeInTheDocument()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('opens the next adjacent trigger without the warm-up delay', async () => {
|
|
108
|
+
render(
|
|
109
|
+
<div>
|
|
110
|
+
<Tooltip content="One">
|
|
111
|
+
<IconButton name="First" />
|
|
112
|
+
</Tooltip>
|
|
113
|
+
<Tooltip content="Two">
|
|
114
|
+
<IconButton name="Second" />
|
|
115
|
+
</Tooltip>
|
|
116
|
+
</div>,
|
|
117
|
+
)
|
|
118
|
+
const [first, second] = screen.getAllByRole('button')
|
|
119
|
+
const firstButton = first!
|
|
120
|
+
const secondButton = second!
|
|
121
|
+
Object.defineProperty(firstButton, 'getBoundingClientRect', rect(0, 0, 32, 32))
|
|
122
|
+
Object.defineProperty(secondButton, 'getBoundingClientRect', rect(40, 0, 32, 32))
|
|
123
|
+
await userEvent.hover(firstButton)
|
|
124
|
+
await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument())
|
|
125
|
+
await userEvent.unhover(firstButton)
|
|
126
|
+
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument())
|
|
127
|
+
await userEvent.hover(secondButton)
|
|
128
|
+
await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument(), { timeout: 150 })
|
|
129
|
+
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('never opens when disabled', async () => {
|
|
133
|
+
render(
|
|
134
|
+
<Tooltip content="Delete" disabled>
|
|
135
|
+
<IconButton name="Delete row" />
|
|
136
|
+
</Tooltip>,
|
|
137
|
+
)
|
|
138
|
+
const trigger = screen.getByRole('button')
|
|
139
|
+
await userEvent.hover(trigger)
|
|
140
|
+
trigger.focus()
|
|
141
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
142
|
+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('runs the action on a touch tap without opening the tooltip', async () => {
|
|
146
|
+
const onPress = vi.fn()
|
|
147
|
+
render(
|
|
148
|
+
<Tooltip content="Delete">
|
|
149
|
+
<IconButton name="Delete row" onPress={onPress} />
|
|
150
|
+
</Tooltip>,
|
|
151
|
+
)
|
|
152
|
+
await userEvent.pointer({ keys: '[TouchA>][/TouchA]', target: screen.getByRole('button') })
|
|
153
|
+
expect(onPress).toHaveBeenCalledTimes(1)
|
|
154
|
+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('supports the controlled open pair', () => {
|
|
158
|
+
const onOpenChange = vi.fn()
|
|
159
|
+
const { rerender } = render(
|
|
160
|
+
<Tooltip content="Delete" open onOpenChange={onOpenChange}>
|
|
161
|
+
<IconButton name="Delete row" />
|
|
162
|
+
</Tooltip>,
|
|
163
|
+
)
|
|
164
|
+
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
|
165
|
+
rerender(
|
|
166
|
+
<Tooltip content="Delete" open={false} onOpenChange={onOpenChange}>
|
|
167
|
+
<IconButton name="Delete row" />
|
|
168
|
+
</Tooltip>,
|
|
169
|
+
)
|
|
170
|
+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('has no axe violations, open, with a named Button trigger', async () => {
|
|
174
|
+
const { container } = render(
|
|
175
|
+
<Tooltip content="Delete the selected rows" role="label" open>
|
|
176
|
+
<IconButton />
|
|
177
|
+
</Tooltip>,
|
|
178
|
+
)
|
|
179
|
+
expect(screen.getByRole('button', { name: 'Delete the selected rows' })).toBeInTheDocument()
|
|
180
|
+
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
|
181
|
+
await axeRtl(container)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('animates opacity only, and nothing under reduced motion', () => {
|
|
185
|
+
const css = cssOf('components/tooltip/Tooltip.css')
|
|
186
|
+
expect(css).toMatch(/@keyframes ty-tooltip-fade\s*\{[^}]*opacity:\s*0/)
|
|
187
|
+
expect(css).not.toMatch(/ty-tooltip-fade\s*\{[^}]*transform/)
|
|
188
|
+
expect(mediaBlock(css, /\(prefers-reduced-motion:\s*reduce\)/)).toMatch(/animation:\s*none/)
|
|
189
|
+
expect(mediaBlock(css, /\(forced-colors:\s*active\)/)).toMatch(/CanvasText/)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('Tooltip in right-to-left (ar)', () => {
|
|
194
|
+
it('opens on focus and passes axe', async () => {
|
|
195
|
+
const { container } = renderRtl(
|
|
196
|
+
<Tooltip content="احذف" placement="end">
|
|
197
|
+
<IconButton name="حذف الصف" />
|
|
198
|
+
</Tooltip>,
|
|
199
|
+
)
|
|
200
|
+
const trigger = rtlDom.screen.getByRole('button')
|
|
201
|
+
// Keyboard focus: react-aria does not open a tooltip on programmatic
|
|
202
|
+
// focus while the interaction modality is still "pointer".
|
|
203
|
+
await userEvent.tab()
|
|
204
|
+
expect(trigger).toHaveFocus()
|
|
205
|
+
await rtlDom.waitFor(() => expect(rtlDom.screen.getByRole('tooltip')).toBeInTheDocument())
|
|
206
|
+
expect(trigger).toHaveAccessibleDescription('احذف')
|
|
207
|
+
await axeRtl(container)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { cloneElement, isValidElement, useId, type ReactElement, type ReactNode } from 'react'
|
|
2
|
+
import { OverlayArrow, Tooltip as AriaTooltip, TooltipTrigger } from 'react-aria-components'
|
|
3
|
+
import { cx } from '../../internal/cx'
|
|
4
|
+
import { devWarning } from '../../internal/dev'
|
|
5
|
+
import { VisuallyHidden } from '../../internal/VisuallyHidden'
|
|
6
|
+
|
|
7
|
+
export type TooltipPlacement = 'top' | 'bottom' | 'start' | 'end'
|
|
8
|
+
export type TooltipRole = 'label' | 'description'
|
|
9
|
+
|
|
10
|
+
export interface TooltipProps {
|
|
11
|
+
/** Hint text; plain text only, never interactive content. */
|
|
12
|
+
content: ReactNode
|
|
13
|
+
/** The focusable trigger (usually an icon-only Button). */
|
|
14
|
+
children: ReactElement
|
|
15
|
+
/** Preferred side; flips when there is no room. */
|
|
16
|
+
placement?: TooltipPlacement
|
|
17
|
+
/** Shows the arrow (default true). */
|
|
18
|
+
showArrow?: boolean
|
|
19
|
+
/** `default` keeps the warm-up delay; `immediate` opens without it. */
|
|
20
|
+
delay?: 'default' | 'immediate'
|
|
21
|
+
/** Never opens (for example when a visible label shows the same text). */
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
/** Controlled open state; rare, for tours and tests. */
|
|
24
|
+
open?: boolean
|
|
25
|
+
defaultOpen?: boolean
|
|
26
|
+
onOpenChange?: (open: boolean) => void
|
|
27
|
+
/**
|
|
28
|
+
* `description` (default): the bubble describes an already-named trigger
|
|
29
|
+
* through `aria-describedby`. `label`: the tooltip text is the trigger's
|
|
30
|
+
* accessible name (icon-only triggers), through a persistent hidden label.
|
|
31
|
+
*/
|
|
32
|
+
role?: TooltipRole
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Warm-up (and cool-down) of the shared tooltip timing, in ms. */
|
|
37
|
+
const WARM_UP_MS = 300
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Short, non-interactive hint for a control, on hover or keyboard focus
|
|
41
|
+
* (spec: wave-5-general/tooltip.md).
|
|
42
|
+
*/
|
|
43
|
+
export function Tooltip(props: TooltipProps) {
|
|
44
|
+
const {
|
|
45
|
+
content,
|
|
46
|
+
children,
|
|
47
|
+
placement = 'top',
|
|
48
|
+
showArrow = true,
|
|
49
|
+
delay = 'default',
|
|
50
|
+
disabled = false,
|
|
51
|
+
open,
|
|
52
|
+
defaultOpen,
|
|
53
|
+
onOpenChange,
|
|
54
|
+
role = 'description',
|
|
55
|
+
className,
|
|
56
|
+
} = props
|
|
57
|
+
devWarning(!isValidElement(children), 'Tooltip: `children` must be one focusable element.')
|
|
58
|
+
|
|
59
|
+
const labelId = useId()
|
|
60
|
+
const asLabel = role === 'label'
|
|
61
|
+
// With role="label" the name must exist while the bubble is closed, so it
|
|
62
|
+
// comes from a persistent hidden label; the trigger keeps any description it
|
|
63
|
+
// brought itself, and RAC's open-state description is suppressed so the text
|
|
64
|
+
// is never spoken twice.
|
|
65
|
+
const child = children as ReactElement<Record<string, unknown>>
|
|
66
|
+
const trigger =
|
|
67
|
+
asLabel && isValidElement(child)
|
|
68
|
+
? cloneElement(child, {
|
|
69
|
+
'aria-labelledby': labelId,
|
|
70
|
+
...(child.props['aria-describedby'] === undefined ? { 'aria-describedby': undefined } : {}),
|
|
71
|
+
})
|
|
72
|
+
: children
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<TooltipTrigger
|
|
76
|
+
delay={delay === 'immediate' ? 0 : WARM_UP_MS}
|
|
77
|
+
closeDelay={delay === 'immediate' ? 0 : WARM_UP_MS}
|
|
78
|
+
isDisabled={disabled}
|
|
79
|
+
{...(open !== undefined ? { isOpen: open } : {})}
|
|
80
|
+
{...(defaultOpen !== undefined ? { defaultOpen } : {})}
|
|
81
|
+
onOpenChange={onOpenChange}
|
|
82
|
+
>
|
|
83
|
+
{trigger}
|
|
84
|
+
{asLabel ? <VisuallyHidden id={labelId}>{content}</VisuallyHidden> : null}
|
|
85
|
+
<AriaTooltip
|
|
86
|
+
className={cx('ty-tooltip', className)}
|
|
87
|
+
placement={placement}
|
|
88
|
+
offset={showArrow ? 8 : 6}
|
|
89
|
+
containerPadding={12}
|
|
90
|
+
>
|
|
91
|
+
{showArrow ? (
|
|
92
|
+
<OverlayArrow className="ty-tooltip__arrow">
|
|
93
|
+
<svg width={10} height={6} viewBox="0 0 10 6" aria-hidden="true">
|
|
94
|
+
<path d="M0 0 L5 6 L10 0" />
|
|
95
|
+
</svg>
|
|
96
|
+
</OverlayArrow>
|
|
97
|
+
) : null}
|
|
98
|
+
<span className="ty-tooltip__content">{content}</span>
|
|
99
|
+
</AriaTooltip>
|
|
100
|
+
</TooltipTrigger>
|
|
101
|
+
)
|
|
102
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,8 +19,10 @@ export * from './components/app-frame/AppFrame'
|
|
|
19
19
|
export * from './components/avatar/Avatar'
|
|
20
20
|
export * from './components/breadcrumbs/Breadcrumbs'
|
|
21
21
|
export * from './components/button/Button'
|
|
22
|
+
export * from './components/calendar/Calendar'
|
|
22
23
|
export * from './components/checkbox/Checkbox'
|
|
23
24
|
export * from './components/data-table/DataTable'
|
|
25
|
+
export * from './components/date-range-field/DateRangeField'
|
|
24
26
|
export * from './components/drawer/Drawer'
|
|
25
27
|
export * from './components/empty-state/EmptyState'
|
|
26
28
|
export * from './components/error-state/ErrorState'
|
|
@@ -51,6 +53,7 @@ export * from './components/text-area/TextArea'
|
|
|
51
53
|
export * from './components/text-field/TextField'
|
|
52
54
|
export * from './components/text/Text'
|
|
53
55
|
export * from './components/toast/Toast'
|
|
56
|
+
export * from './components/tooltip/Tooltip'
|
|
54
57
|
|
|
55
58
|
// Wave 2 and wave 4 (one barrel per group)
|
|
56
59
|
export * from './groups/forms-a'
|
|
@@ -22,6 +22,23 @@ export const coreEs: CoreMessages = {
|
|
|
22
22
|
`${es.n(n)} de ${es.n(max)} ${es.word(max, { one: 'carácter', other: 'caracteres' })}, ${es.n(n - max)} por encima del límite`,
|
|
23
23
|
},
|
|
24
24
|
select: { placeholder: 'Seleccione…', done: 'Listo' },
|
|
25
|
+
calendar: {
|
|
26
|
+
previousMonth: (target) => `Mes anterior, ${target}`,
|
|
27
|
+
nextMonth: (target) => `Mes siguiente, ${target}`,
|
|
28
|
+
monthPicker: 'Mes',
|
|
29
|
+
yearPicker: 'Año',
|
|
30
|
+
},
|
|
31
|
+
dateRangeField: {
|
|
32
|
+
start: 'Fecha inicial',
|
|
33
|
+
end: 'Fecha final',
|
|
34
|
+
presets: 'Períodos rápidos',
|
|
35
|
+
apply: 'Aplicar',
|
|
36
|
+
cancel: 'Cancelar',
|
|
37
|
+
endBeforeStart: 'La fecha final es anterior a la fecha inicial.',
|
|
38
|
+
tooManyDays: (max) => `El período es más largo que ${es.n(max)} ${es.word(max, { one: 'día', other: 'días' })}.`,
|
|
39
|
+
unavailableDay: 'El período incluye un día que no se puede elegir.',
|
|
40
|
+
outOfBounds: 'El período incluye un día fuera de las fechas permitidas.',
|
|
41
|
+
},
|
|
25
42
|
notice: { toneWord: { danger: 'Error:', warning: 'Atención:', info: 'Información:', success: 'Éxito:' } },
|
|
26
43
|
empty: {
|
|
27
44
|
'no-data': { title: 'Todavía no hay nada aquí', description: 'Los elementos aparecen aquí cuando se agregan.' },
|