@datatechsolutions/tympan 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -15,9 +15,12 @@
15
15
  gap: var(--ty-space-1);
16
16
  outline: none;
17
17
  }
18
+ /* Columns follow the surface's width, not the viewport's: a dialog is
19
+ narrower than the window, and three viewport-driven columns squeezed
20
+ names such as "Español" into the code beside them. */
18
21
  .ty-locale-picker__list[data-layout='grid'] {
19
22
  display: grid;
20
- grid-template-columns: repeat(2, minmax(0, 1fr));
23
+ grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
21
24
  }
22
25
  .ty-locale-picker__option {
23
26
  display: flex;
@@ -53,6 +56,7 @@
53
56
  min-inline-size: 0;
54
57
  }
55
58
  .ty-locale-picker__code {
59
+ flex: none;
56
60
  color: var(--ty-ink-3);
57
61
  font-family: var(--ty-font-mono);
58
62
  font-size: var(--ty-font-size-meta);
@@ -72,12 +76,6 @@
72
76
  line-height: var(--ty-font-line-height-meta);
73
77
  }
74
78
 
75
- @media (min-width: 640px) {
76
- .ty-locale-picker__list[data-layout='grid'] {
77
- grid-template-columns: repeat(3, minmax(0, 1fr));
78
- }
79
- }
80
-
81
79
  @media (forced-colors: active) {
82
80
  .ty-locale-picker__option[data-selected] {
83
81
  background: Highlight;
@@ -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'