asma-ui-core 3.85.0 → 3.86.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.
- package/Readme.md +2 -0
- package/dist/asma-ui-core.css +1 -1
- package/dist/components/inputs/button/StyledButton.js +4 -4
- package/dist/components/inputs/slider/StyledSlider.js +158 -107
- package/dist/components/navigation/menu/StyledMenuItem.js +6 -6
- package/dist/components/navigation/tabs/StyledTab.js +7 -7
- package/dist/src/components/inputs/slider/StyledSlider.d.ts +4 -1
- package/package.json +1 -1
- package/src/styles/index.css +103 -0
- package/src/styles/touchReadiness.interaction.test.tsx +161 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { afterEach, describe, it } from 'vitest'
|
|
2
|
+
import { expect } from 'storybook/test'
|
|
3
|
+
import { StyledButton } from 'src/components/inputs/button/StyledButton'
|
|
4
|
+
import { StyledMenuItem } from 'src/components/navigation/menu/StyledMenuItem'
|
|
5
|
+
import { StyledTab } from 'src/components/navigation/tabs/StyledTab'
|
|
6
|
+
import { StyledTabs } from 'src/components/navigation/tabs/StyledTabs'
|
|
7
|
+
import { cleanup, mount } from 'src/test-utils/renderInteraction'
|
|
8
|
+
import { page } from 'src/test-utils/interaction-api'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ASMA-8210 (TB-02) — the touch-readiness primitives in `src/styles/index.css`.
|
|
12
|
+
*
|
|
13
|
+
* Real Chromium rather than the unit project, because every claim here is a computed-style or a
|
|
14
|
+
* hit-testing question: `::after` geometry, `touch-action` and "is this point inside the target"
|
|
15
|
+
* are exactly what a DOM emulator invents. The suite mounts components directly (no stories), so
|
|
16
|
+
* it adds no VRT baseline — see `docs/a11y-keyboard-contract.md`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The `min-height` the mobile-gated `.asma-touch-target` rule declares, or null if it is missing. */
|
|
20
|
+
const touchTargetMinHeightUnderMobileMedia = (): string | null => {
|
|
21
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
22
|
+
let rules: CSSRule[]
|
|
23
|
+
try {
|
|
24
|
+
rules = Array.from(sheet.cssRules)
|
|
25
|
+
} catch {
|
|
26
|
+
continue // cross-origin sheet; ours are inline <style> tags, so nothing of interest here
|
|
27
|
+
}
|
|
28
|
+
for (const rule of rules) {
|
|
29
|
+
if (!(rule instanceof CSSMediaRule) || !rule.conditionText.includes('743px')) continue
|
|
30
|
+
for (const inner of Array.from(rule.cssRules)) {
|
|
31
|
+
if (inner instanceof CSSStyleRule && inner.selectorText.includes('.asma-touch-target')) {
|
|
32
|
+
return inner.style.getPropertyValue('min-height')
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const alphaOf = (color: string): number => {
|
|
41
|
+
const channels = /rgba?\(([^)]+)\)/.exec(color)?.[1]?.split(',') ?? []
|
|
42
|
+
// rgb() has no alpha channel, so an unparsed or 3-channel colour is fully opaque.
|
|
43
|
+
return channels.length < 4 ? 1 : Number(channels[3])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('touch-readiness primitives', () => {
|
|
47
|
+
afterEach(cleanup)
|
|
48
|
+
|
|
49
|
+
it('gives .asma-hit-area a 44px pointer target without changing the drawn size', async () => {
|
|
50
|
+
// Fixed positioning pins the probe away from the body edge so the hit tests below are
|
|
51
|
+
// arithmetic, not a guess about layout. A fixed box is still the containing block its
|
|
52
|
+
// ::after resolves against.
|
|
53
|
+
const { container } = mount(
|
|
54
|
+
<button className='asma-hit-area' style={{ position: 'fixed', left: 100, top: 100, width: 24, height: 24 }}>
|
|
55
|
+
probe
|
|
56
|
+
</button>,
|
|
57
|
+
)
|
|
58
|
+
const probe = container.querySelector('button')!
|
|
59
|
+
|
|
60
|
+
// The class supplies `position: relative` WITHOUT `!important` — the rule lives outside
|
|
61
|
+
// `@layer utilities` precisely so Tailwind's `important: true` cannot importantify it
|
|
62
|
+
// (ASMA-8210 review). If this assertion fails, the rule regressed back into the layer and
|
|
63
|
+
// the inline `fixed` (and any Tailwind position utility) would silently lose again.
|
|
64
|
+
await expect(getComputedStyle(probe).position).toBe('fixed')
|
|
65
|
+
|
|
66
|
+
// The visual box is untouched — this is hit-area padding, not a size change (ASMA-8210).
|
|
67
|
+
const box = probe.getBoundingClientRect()
|
|
68
|
+
await expect(box.width).toBe(24)
|
|
69
|
+
await expect(box.height).toBe(24)
|
|
70
|
+
|
|
71
|
+
const overlay = getComputedStyle(probe, '::after')
|
|
72
|
+
await expect(overlay.minWidth).toBe('44px')
|
|
73
|
+
await expect(overlay.minHeight).toBe('44px')
|
|
74
|
+
|
|
75
|
+
// 92px is outside the 24px box (which starts at 100) but inside the 44px overlay centred on
|
|
76
|
+
// it (90 → 134). A press there must still land on the control.
|
|
77
|
+
await expect(document.elementFromPoint(92, 112)).toBe(probe)
|
|
78
|
+
// …and the target stays bounded: 85px is outside the overlay entirely.
|
|
79
|
+
await expect(document.elementFromPoint(85, 112)).not.toBe(probe)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('suppresses the tap flash and the double-tap-zoom delay on a pressable control', async () => {
|
|
83
|
+
const { container } = mount(<StyledMenuItem>Item</StyledMenuItem>)
|
|
84
|
+
const item = container.querySelector('li')!
|
|
85
|
+
|
|
86
|
+
await expect(item.classList.contains('asma-pressable')).toBe(true)
|
|
87
|
+
await expect(item.classList.contains('asma-touch-target')).toBe(true)
|
|
88
|
+
|
|
89
|
+
const style = getComputedStyle(item)
|
|
90
|
+
await expect(style.touchAction).toBe('manipulation')
|
|
91
|
+
await expect(alphaOf(style.getPropertyValue('-webkit-tap-highlight-color'))).toBe(0)
|
|
92
|
+
// Resting opacity is untouched — the pressed feedback lives entirely under `:active`, which
|
|
93
|
+
// is why no VRT baseline moves.
|
|
94
|
+
await expect(style.opacity).toBe('1')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('keeps the 44px row growth on phones only, so tablet and desktop are unchanged', async () => {
|
|
98
|
+
await expect(touchTargetMinHeightUnderMobileMedia()).toBe('var(--asma-touch-target-size, 44px)')
|
|
99
|
+
|
|
100
|
+
// The interaction project runs at 1280x720 (vite.config.ts), i.e. the VRT viewport. The rule
|
|
101
|
+
// above must not apply here, or every desktop baseline would shift.
|
|
102
|
+
await expect(window.matchMedia('(max-width: 743px)').matches).toBe(false)
|
|
103
|
+
const { container } = mount(<StyledMenuItem>Item</StyledMenuItem>)
|
|
104
|
+
await expect(getComputedStyle(container.querySelector('li')!).minHeight).toBe('40px')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('on a phone viewport, grows sub-44px rows and never shrinks a designed minimum', async () => {
|
|
108
|
+
// 390x700 puts the tester iframe under the 743px gate — the cascade CI otherwise never
|
|
109
|
+
// sees (interaction and VRT both run at 1280x720). Guards ASMA-8210 review B1: the
|
|
110
|
+
// touch-target utility is a later-source override, not a floor, so a mis-scoped opt-in
|
|
111
|
+
// would silently SHRINK a tab's effective 48x90 minimum to 44px. Both tab variants rest
|
|
112
|
+
// at 48px — the small variant's `min-h-10` loses to the base `min-h-12` by emitted
|
|
113
|
+
// order (pre-existing quirk, `cn` is plain clsx) — so NEITHER may carry the override.
|
|
114
|
+
await page.viewport(390, 700)
|
|
115
|
+
try {
|
|
116
|
+
await expect(window.matchMedia('(max-width: 743px)').matches).toBe(true)
|
|
117
|
+
const { container } = mount(
|
|
118
|
+
<>
|
|
119
|
+
<StyledTabs value={0}>
|
|
120
|
+
<StyledTab label='Default' />
|
|
121
|
+
</StyledTabs>
|
|
122
|
+
<StyledTabs size='small' value={0}>
|
|
123
|
+
<StyledTab label='Small' />
|
|
124
|
+
</StyledTabs>
|
|
125
|
+
<StyledMenuItem>Item</StyledMenuItem>
|
|
126
|
+
</>,
|
|
127
|
+
)
|
|
128
|
+
const tabs = Array.from(container.querySelectorAll('[role="tab"]'))
|
|
129
|
+
await expect(tabs.length).toBe(2)
|
|
130
|
+
|
|
131
|
+
// Tabs carry no touch-target class: the effective 48px/90px minimums survive on
|
|
132
|
+
// both variants, and neither shrinks.
|
|
133
|
+
for (const tab of tabs) {
|
|
134
|
+
const tabStyle = getComputedStyle(tab)
|
|
135
|
+
await expect(tabStyle.minHeight).toBe('48px')
|
|
136
|
+
await expect(tabStyle.minWidth).toBe('90px')
|
|
137
|
+
await expect(tab.classList.contains('asma-touch-target')).toBe(false)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Menu item: 40px → 44px, and it must not gain a min-width it never declared
|
|
141
|
+
// (Chromium resolves the initial `min-width: auto` to 0px on a block-level li).
|
|
142
|
+
const itemStyle = getComputedStyle(container.querySelector('li')!)
|
|
143
|
+
await expect(itemStyle.minHeight).toBe('44px')
|
|
144
|
+
await expect(itemStyle.minWidth).toBe('0px')
|
|
145
|
+
} finally {
|
|
146
|
+
// The desktop viewport every other test in this project assumes (vite.config.ts).
|
|
147
|
+
await page.viewport(1280, 720)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('opts the button into touch readiness without dimming its designed pressed state', async () => {
|
|
152
|
+
const { container } = mount(<StyledButton dataTest='touch-ready-probe'>Save</StyledButton>)
|
|
153
|
+
const button = container.querySelector('button')!
|
|
154
|
+
|
|
155
|
+
await expect(button.classList.contains('asma-touch-ready')).toBe(true)
|
|
156
|
+
// StyledButton.module.scss already paints `:active` from the button colour tokens. Taking
|
|
157
|
+
// `.asma-pressable` as well would layer a 0.7 opacity over those designed colours.
|
|
158
|
+
await expect(button.classList.contains('asma-pressable')).toBe(false)
|
|
159
|
+
await expect(getComputedStyle(button).touchAction).toBe('manipulation')
|
|
160
|
+
})
|
|
161
|
+
})
|