asma-ui-core 3.86.2 → 3.87.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/components/custom/pathfinder-card/PathfinderCard.js +28 -28
- package/dist/components/data-display/chip/StyledChip.js +9 -9
- package/dist/components/feedback/snack-bar/components/StyledInfoSnackbar.js +10 -10
- package/dist/components/inputs/input-field/StyledInputField.js +16 -16
- package/dist/components/inputs/search-field/StyledSearchField.js +10 -10
- package/dist/components/inputs/select/StyledSelect.js +1 -1
- package/dist/components/inputs/select/StyledSelectItem.js +7 -7
- package/dist/components/inputs/select-autocomplete/StyledSelectAutocomplete.js +54 -54
- package/dist/components/inputs/switch/base-ui/StyledSwitch.js +22 -22
- package/dist/components/navigation/link/StyledLink.js +8 -8
- package/dist/components/navigation/listbox/Listbox.js +67 -66
- package/dist/components/navigation/title-chevron/StyledTitleChevron.js +7 -7
- package/dist/components/utils/accordion/base-ui/StyledAccordionSummary.js +14 -14
- package/package.json +1 -1
- package/src/styles/touchHardening.interaction.test.tsx +269 -0
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
import { ChevronDownIcon as
|
|
1
|
+
import { ChevronDownIcon as S } from "../../../icons/chevron-down-icon/ChevronDownIcon.js";
|
|
2
2
|
import e from "./StyledAccordion.module.js";
|
|
3
3
|
import { useAccordionContext as A } from "./AccordionContext.js";
|
|
4
4
|
import o from "clsx";
|
|
5
5
|
import "react";
|
|
6
6
|
import { Fragment as c, jsx as t, jsxs as l } from "react/jsx-runtime";
|
|
7
|
-
var _ = ({ size: d = "large", expandChevronRight:
|
|
8
|
-
const { open:
|
|
7
|
+
var _ = ({ size: d = "large", expandChevronRight: a, className: m, headerClassName: p, sx: g, children: i, onClick: x, ...h }) => {
|
|
8
|
+
const { open: n, toggle: u, disabled: f, triggerId: y, panelId: I } = A(), r = d === "small", b = {
|
|
9
9
|
"--trigger-height": r ? "48px" : "72px",
|
|
10
10
|
"--trigger-padding-x": r ? "8px" : "16px",
|
|
11
11
|
"--trigger-font-size": r ? "14px" : "18px",
|
|
12
12
|
"--chevron-size": r ? "20px" : "24px",
|
|
13
|
-
...
|
|
14
|
-
}, s = /* @__PURE__ */ t(
|
|
15
|
-
className: o(e.TriggerIcon,
|
|
13
|
+
...g
|
|
14
|
+
}, s = /* @__PURE__ */ t(S, {
|
|
15
|
+
className: o(e.TriggerIcon, a && e.TriggerIconRight),
|
|
16
16
|
"data-state-icon": !0
|
|
17
17
|
});
|
|
18
18
|
return /* @__PURE__ */ t("h3", {
|
|
19
|
-
className: o(e.Header,
|
|
19
|
+
className: o(e.Header, p),
|
|
20
20
|
children: /* @__PURE__ */ t("button", {
|
|
21
21
|
...h,
|
|
22
22
|
type: "button",
|
|
23
23
|
id: y,
|
|
24
|
-
"aria-expanded":
|
|
24
|
+
"aria-expanded": n,
|
|
25
25
|
"aria-controls": I,
|
|
26
26
|
disabled: f,
|
|
27
|
-
onClick: (
|
|
28
|
-
x?.(
|
|
27
|
+
onClick: (v) => {
|
|
28
|
+
x?.(v), u();
|
|
29
29
|
},
|
|
30
|
-
className: o(e.Trigger,
|
|
31
|
-
style:
|
|
32
|
-
"data-panel-open":
|
|
33
|
-
children:
|
|
30
|
+
className: o(e.Trigger, "asma-pressable", m),
|
|
31
|
+
style: b,
|
|
32
|
+
"data-panel-open": n ? "" : void 0,
|
|
33
|
+
children: a ? /* @__PURE__ */ l(c, { children: [i, s] }) : /* @__PURE__ */ l(c, { children: [s, i] })
|
|
34
34
|
})
|
|
35
35
|
});
|
|
36
36
|
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { afterEach, describe, it } from 'vitest'
|
|
2
|
+
import { expect, fn, page, userEvent, waitFor } from 'src/test-utils/interaction-api'
|
|
3
|
+
import { StyledAccordion } from 'src/components/utils/accordion/base-ui/StyledAccordion'
|
|
4
|
+
import { StyledAccordionSummary } from 'src/components/utils/accordion/base-ui/StyledAccordionSummary'
|
|
5
|
+
import { StyledChip } from 'src/components/data-display/chip/StyledChip'
|
|
6
|
+
import { Listbox } from 'src/components/navigation/listbox/Listbox'
|
|
7
|
+
import { PathfinderCard } from 'src/components/custom/pathfinder-card/PathfinderCard'
|
|
8
|
+
import { StyledInputField } from 'src/components/inputs/input-field/StyledInputField'
|
|
9
|
+
import { StyledLink } from 'src/components/navigation/link/StyledLink'
|
|
10
|
+
import { StyledSearchField } from 'src/components/inputs/search-field/StyledSearchField'
|
|
11
|
+
import { StyledSelect } from 'src/components/inputs/select/StyledSelect'
|
|
12
|
+
import { StyledSelectAutocomplete } from 'src/components/inputs/select-autocomplete/StyledSelectAutocomplete'
|
|
13
|
+
import { StyledSelectItem } from 'src/components/inputs/select/StyledSelectItem'
|
|
14
|
+
import { StyledSwitch } from 'src/components/inputs/switch/base-ui/StyledSwitch'
|
|
15
|
+
import { StyledTitleChevron } from 'src/components/navigation/title-chevron/StyledTitleChevron'
|
|
16
|
+
import { cleanup, mount } from 'src/test-utils/renderInteraction'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ASMA-8220 (TB-14) — the touch-hardening sweep that applies the ASMA-8210 (TB-02) primitives to
|
|
20
|
+
* the components on the TB-02 audit list.
|
|
21
|
+
*
|
|
22
|
+
* TB-02's own suite (`touchReadiness.interaction.test.tsx`) proves the four utilities behave; this
|
|
23
|
+
* one proves each component took the RIGHT one and that nothing moved at rest. Real Chromium, and
|
|
24
|
+
* components mounted directly rather than through stories, for the same reasons as TB-02: computed
|
|
25
|
+
* style and hit-testing are what a DOM emulator invents, and a story would add a VRT baseline.
|
|
26
|
+
*
|
|
27
|
+
* `StyledInfoSnackbar`'s close button is the one opt-in not mounted here — it needs the notistack
|
|
28
|
+
* provider, and it is a plain-string `asma-touch-ready` opt-in in the same category as the ones
|
|
29
|
+
* proven below.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const alphaOf = (color: string): number => {
|
|
33
|
+
const channels = /rgba?\(([^)]+)\)/.exec(color)?.[1]?.split(',') ?? []
|
|
34
|
+
// rgb() has no alpha channel, so an unparsed or 3-channel colour is fully opaque.
|
|
35
|
+
return channels.length < 4 ? 1 : Number(channels[3])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every claim the two opt-in classes make at rest: no tap flash, no zoom delay, no dimming. */
|
|
39
|
+
const expectTouchOptIn = async (element: Element, expected: 'asma-touch-ready' | 'asma-pressable'): Promise<void> => {
|
|
40
|
+
const other = expected === 'asma-touch-ready' ? 'asma-pressable' : 'asma-touch-ready'
|
|
41
|
+
await expect(element.classList.contains(expected)).toBe(true)
|
|
42
|
+
// Never both: `.asma-pressable` layers a 0.7 opacity that would wash out a designed `:active`.
|
|
43
|
+
await expect(element.classList.contains(other)).toBe(false)
|
|
44
|
+
|
|
45
|
+
const style = getComputedStyle(element)
|
|
46
|
+
await expect(style.touchAction).toBe('manipulation')
|
|
47
|
+
await expect(alphaOf(style.getPropertyValue('-webkit-tap-highlight-color'))).toBe(0)
|
|
48
|
+
// The pressed feedback lives entirely under `:active`, which is why no VRT baseline moves.
|
|
49
|
+
await expect(style.opacity).toBe('1')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('ASMA-8220 touch hardening', () => {
|
|
53
|
+
afterEach(cleanup)
|
|
54
|
+
|
|
55
|
+
it('takes touch-ready on controls that already paint a designed :active', async () => {
|
|
56
|
+
const { container } = mount(
|
|
57
|
+
<>
|
|
58
|
+
<StyledChip dataTest='chip' label='Interactive' clickable />
|
|
59
|
+
<StyledLink href='#' content='Link' />
|
|
60
|
+
<StyledSwitch dataTest='switch' />
|
|
61
|
+
<StyledTitleChevron dataTest='chevron' onClick={fn()}>
|
|
62
|
+
Title
|
|
63
|
+
</StyledTitleChevron>
|
|
64
|
+
<StyledSelect dataTest='select' />
|
|
65
|
+
</>,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
for (const selector of [
|
|
69
|
+
'[data-testid="chip"]',
|
|
70
|
+
'a',
|
|
71
|
+
'[role="switch"]',
|
|
72
|
+
'[data-testid="chevron"]',
|
|
73
|
+
'[data-testid="select"]',
|
|
74
|
+
]) {
|
|
75
|
+
const element = container.querySelector(selector)
|
|
76
|
+
await expect(element, selector).not.toBe(null)
|
|
77
|
+
await expectTouchOptIn(element!, 'asma-touch-ready')
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('takes pressable on controls that had no pressed state at all', async () => {
|
|
82
|
+
const { container } = mount(
|
|
83
|
+
<>
|
|
84
|
+
<ul>
|
|
85
|
+
<StyledSelectItem>Option</StyledSelectItem>
|
|
86
|
+
</ul>
|
|
87
|
+
<StyledAccordion defaultExpanded={false}>
|
|
88
|
+
<StyledAccordionSummary data-testid='accordion'>Summary</StyledAccordionSummary>
|
|
89
|
+
</StyledAccordion>
|
|
90
|
+
<StyledSearchField dataTest='search' label='Search' value='typed' onClear={fn()} />
|
|
91
|
+
<StyledInputField
|
|
92
|
+
dataTest='field'
|
|
93
|
+
label='Field'
|
|
94
|
+
allowClear
|
|
95
|
+
value='typed'
|
|
96
|
+
onChange={fn()}
|
|
97
|
+
onClear={fn()}
|
|
98
|
+
/>
|
|
99
|
+
<PathfinderCard
|
|
100
|
+
expanded={false}
|
|
101
|
+
onToggleExpanded={fn()}
|
|
102
|
+
items={[{ id: 'a', order: 0, render: () => <span>A</span> }]}
|
|
103
|
+
/>
|
|
104
|
+
</>,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for (const selector of [
|
|
108
|
+
'[role="option"]',
|
|
109
|
+
'[data-testid="accordion"]',
|
|
110
|
+
'[data-testid="styled-search-clear-icon"]',
|
|
111
|
+
// StyledInputField's own clear button — the search field's is a separate element.
|
|
112
|
+
'[data-testid="field-clear"]',
|
|
113
|
+
'[role="button"]',
|
|
114
|
+
]) {
|
|
115
|
+
const element = container.querySelector(selector)
|
|
116
|
+
await expect(element, selector).not.toBe(null)
|
|
117
|
+
await expectTouchOptIn(element!, 'asma-pressable')
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('takes pressable on the headless Listbox, whose sizing stays the consumer’s', async () => {
|
|
122
|
+
const { container } = mount(
|
|
123
|
+
<Listbox value='a' onChange={fn()}>
|
|
124
|
+
<Listbox.Button>Open</Listbox.Button>
|
|
125
|
+
<Listbox.Options>
|
|
126
|
+
<Listbox.Option value='a'>{() => 'A'}</Listbox.Option>
|
|
127
|
+
</Listbox.Options>
|
|
128
|
+
</Listbox>,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const button = container.querySelector('button')!
|
|
132
|
+
await expectTouchOptIn(button, 'asma-pressable')
|
|
133
|
+
|
|
134
|
+
// `.Options` renders nothing while closed, so the option only exists after a real click.
|
|
135
|
+
await userEvent.click(button)
|
|
136
|
+
await waitFor(() => expect(container.querySelector('[role="option"]')).not.toBe(null))
|
|
137
|
+
await expectTouchOptIn(container.querySelector('[role="option"]')!, 'asma-pressable')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('takes pressable on the autocomplete option rows, which live in a portal', async () => {
|
|
141
|
+
mount(
|
|
142
|
+
<StyledSelectAutocomplete<string, false, false, false>
|
|
143
|
+
dataTest='ac'
|
|
144
|
+
options={['Alpha', 'Bravo']}
|
|
145
|
+
value={null}
|
|
146
|
+
onChange={fn()}
|
|
147
|
+
renderInput={(params) => <StyledInputField {...params} dataTest='ac-input' label='Team' />}
|
|
148
|
+
/>,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
// The listbox is portalled out of `container` and only exists once open, so this one is
|
|
152
|
+
// queried off the document and after a real ArrowDown.
|
|
153
|
+
document.querySelector<HTMLInputElement>('input[role="combobox"]')!.focus()
|
|
154
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
155
|
+
await waitFor(() => expect(document.querySelector('li[role="option"]')).not.toBe(null))
|
|
156
|
+
|
|
157
|
+
// The class rides `optionRowClassName`, which is also what a custom `renderOption` spreading
|
|
158
|
+
// `{...props}` receives — so this covers the consumer-rendered rows too.
|
|
159
|
+
await expectTouchOptIn(document.querySelector('li[role="option"]')!, 'asma-pressable')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('gives the switch a 44px pointer target without redrawing its 38x22 track', async () => {
|
|
163
|
+
// Padding keeps the overlay clear of the viewport edge, so the hit tests below are
|
|
164
|
+
// arithmetic rather than a guess about clipping.
|
|
165
|
+
const { container } = mount(
|
|
166
|
+
<div style={{ padding: 60 }}>
|
|
167
|
+
<StyledSwitch dataTest='switch' />
|
|
168
|
+
</div>,
|
|
169
|
+
)
|
|
170
|
+
const toggle = container.querySelector<HTMLElement>('[role="switch"]')!
|
|
171
|
+
|
|
172
|
+
// The drawn track is untouched — this is hit-area padding, not a size change. Figma specs a
|
|
173
|
+
// 38x22 track inside a 54x32 touch area, so the 44px overlay stays inside what design drew.
|
|
174
|
+
const box = toggle.getBoundingClientRect()
|
|
175
|
+
await expect(box.width).toBe(38)
|
|
176
|
+
await expect(box.height).toBe(22)
|
|
177
|
+
|
|
178
|
+
const overlay = getComputedStyle(toggle, '::after')
|
|
179
|
+
await expect(overlay.minWidth).toBe('44px')
|
|
180
|
+
await expect(overlay.minHeight).toBe('44px')
|
|
181
|
+
|
|
182
|
+
// 16px above the 22px-tall track's centre is outside the track (half-height 11) but inside
|
|
183
|
+
// the 44px overlay centred on it (half-height 22). A press there must still hit the switch.
|
|
184
|
+
const centerX = box.left + box.width / 2
|
|
185
|
+
const centerY = box.top + box.height / 2
|
|
186
|
+
await expect(document.elementFromPoint(centerX, centerY - 16)).toBe(toggle)
|
|
187
|
+
// …and the target stays bounded: 30px above the centre is outside the overlay entirely.
|
|
188
|
+
await expect(document.elementFromPoint(centerX, centerY - 30)).not.toBe(toggle)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('leaves a non-interactive chip out of the sweep entirely', async () => {
|
|
192
|
+
const { container } = mount(
|
|
193
|
+
<>
|
|
194
|
+
<StyledChip dataTest='plain' label='Plain' />
|
|
195
|
+
<StyledChip dataTest='readonly' label='Read only' clickable readOnly />
|
|
196
|
+
</>,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
// The opt-in is gated on `interactive`, so a display-only chip gains nothing — including
|
|
200
|
+
// `touch-action: manipulation`, which would be meaningless on something you cannot press.
|
|
201
|
+
for (const testId of ['plain', 'readonly']) {
|
|
202
|
+
const chip = container.querySelector(`[data-testid="${testId}"]`)!
|
|
203
|
+
await expect(chip.classList.contains('asma-touch-ready')).toBe(false)
|
|
204
|
+
await expect(chip.classList.contains('asma-pressable')).toBe(false)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('adds no mobile size override, so phone geometry matches desktop', async () => {
|
|
209
|
+
// The one failure mode TB-02 review B1 called out: `.asma-touch-target` is a later-source
|
|
210
|
+
// override, NOT a `max()` floor, so opting a component in whose effective min-height is
|
|
211
|
+
// already above 44px SHRINKS it (the StyledTab 48x90 incident). Nothing in this sweep takes
|
|
212
|
+
// it — every component here either already clears 44px or is sized by its consumer — and
|
|
213
|
+
// this test is what keeps that true. 390x700 puts the tester iframe under the 743px gate
|
|
214
|
+
// that neither the interaction project nor VRT (both 1280x720) would otherwise cross.
|
|
215
|
+
await page.viewport(390, 700)
|
|
216
|
+
try {
|
|
217
|
+
await expect(window.matchMedia('(max-width: 743px)').matches).toBe(true)
|
|
218
|
+
const { container } = mount(
|
|
219
|
+
<>
|
|
220
|
+
<StyledChip dataTest='chip' label='Interactive' clickable />
|
|
221
|
+
<StyledSelect dataTest='select' />
|
|
222
|
+
<StyledTitleChevron dataTest='chevron' onClick={fn()}>
|
|
223
|
+
Title
|
|
224
|
+
</StyledTitleChevron>
|
|
225
|
+
<StyledAccordion defaultExpanded={false}>
|
|
226
|
+
<StyledAccordionSummary data-testid='accordion'>Summary</StyledAccordionSummary>
|
|
227
|
+
</StyledAccordion>
|
|
228
|
+
<ul>
|
|
229
|
+
<StyledSelectItem>Option</StyledSelectItem>
|
|
230
|
+
</ul>
|
|
231
|
+
<StyledInputField
|
|
232
|
+
dataTest='field'
|
|
233
|
+
label='Field'
|
|
234
|
+
allowClear
|
|
235
|
+
value='typed'
|
|
236
|
+
onChange={fn()}
|
|
237
|
+
onClear={fn()}
|
|
238
|
+
/>
|
|
239
|
+
</>,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
for (const element of Array.from(container.querySelectorAll('*'))) {
|
|
243
|
+
await expect(element.classList.contains('asma-touch-target'), element.tagName).toBe(false)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Designed heights survive: the chip's Figma 32px, the select trigger's 40px (kept in
|
|
247
|
+
// step with StyledInputField), and the accordion trigger's 72px.
|
|
248
|
+
await expect(container.querySelector('[data-testid="chip"]')!.getBoundingClientRect().height).toBe(32)
|
|
249
|
+
await expect(container.querySelector('[data-testid="select"]')!.getBoundingClientRect().height).toBe(40)
|
|
250
|
+
await expect(container.querySelector('[data-testid="accordion"]')!.getBoundingClientRect().height).toBe(72)
|
|
251
|
+
// The chevron and the option row already clear 44px without any override.
|
|
252
|
+
await expect(
|
|
253
|
+
container.querySelector('[data-testid="chevron"]')!.getBoundingClientRect().height,
|
|
254
|
+
).toBeGreaterThanOrEqual(44)
|
|
255
|
+
await expect(
|
|
256
|
+
container.querySelector('[role="option"]')!.getBoundingClientRect().height,
|
|
257
|
+
).toBeGreaterThanOrEqual(44)
|
|
258
|
+
// And the clear button stays the 18px icon in 2px padding it has always been — the one
|
|
259
|
+
// opt-in here that does NOT reach 44px, deliberately: a hit area that big would sit over
|
|
260
|
+
// the field's own text (see the comment at its call site).
|
|
261
|
+
await expect(container.querySelector('[data-testid="field-clear"]')!.getBoundingClientRect().height).toBe(
|
|
262
|
+
22,
|
|
263
|
+
)
|
|
264
|
+
} finally {
|
|
265
|
+
// The desktop viewport every other test in this project assumes (vite.config.ts).
|
|
266
|
+
await page.viewport(1280, 720)
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
})
|