asma-ui-core 3.84.0 → 3.86.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.
Files changed (24) hide show
  1. package/Readme.md +2 -0
  2. package/dist/asma-ui-core.css +1 -1
  3. package/dist/components/icons/index.js +2 -0
  4. package/dist/components/icons/prohibition-icon/ProhibitionIcon.js +2 -2
  5. package/dist/components/icons/prohibition-large-icon/ProhibitionLargeIcon.js +17 -0
  6. package/dist/components/icons/prohibition-large-icon/index.js +1 -0
  7. package/dist/components/icons/stylus-note-icon/StylusNoteIcon.js +4 -4
  8. package/dist/components/icons/stylus-note-large-icon/StylusNoteLargeIcon.js +17 -0
  9. package/dist/components/icons/stylus-note-large-icon/index.js +1 -0
  10. package/dist/components/inputs/button/StyledButton.js +4 -4
  11. package/dist/components/inputs/textarea/StyledTextarea.module.js +4 -4
  12. package/dist/components/navigation/menu/StyledMenuItem.js +6 -6
  13. package/dist/components/navigation/tabs/StyledTab.js +7 -7
  14. package/dist/index.js +256 -252
  15. package/dist/src/components/icons/index.d.ts +2 -0
  16. package/dist/src/components/icons/prohibition-icon/ProhibitionIcon.d.ts +1 -1
  17. package/dist/src/components/icons/prohibition-large-icon/ProhibitionLargeIcon.d.ts +7 -0
  18. package/dist/src/components/icons/prohibition-large-icon/index.d.ts +1 -0
  19. package/dist/src/components/icons/stylus-note-icon/StylusNoteIcon.d.ts +1 -1
  20. package/dist/src/components/icons/stylus-note-large-icon/StylusNoteLargeIcon.d.ts +7 -0
  21. package/dist/src/components/icons/stylus-note-large-icon/index.d.ts +1 -0
  22. package/package.json +1 -1
  23. package/src/styles/index.css +103 -0
  24. package/src/styles/touchReadiness.interaction.test.tsx +161 -0
@@ -165,7 +165,9 @@ export * from './file-xls-icon';
165
165
  export * from './file-image-icon';
166
166
  export * from './draw-icon';
167
167
  export * from './prohibition-icon';
168
+ export * from './prohibition-large-icon';
168
169
  export * from './stylus-note-icon';
170
+ export * from './stylus-note-large-icon';
169
171
  export * from './print-icon';
170
172
  export * from './arrow-shrink-icon';
171
173
  export * from './indeterminate-icon';
@@ -1,3 +1,3 @@
1
1
  import { SVGProps } from 'react';
2
- /** Material Symbols `block` glyph, exactly as used by the Ad Voca Figma (rejected states). */
2
+ /** Design-system `not-allowed` glyph (24px grid), as used on the Ad Voca Reject buttons. */
3
3
  export declare function ProhibitionIcon(props: SVGProps<SVGSVGElement>): JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { SVGProps } from 'react';
2
+ /**
3
+ * Thin-stroke `block` illustration on a 140px grid — the exact Ad Voca Figma export used inside
4
+ * the tinted result-page circle ("Document rejected"). Not interchangeable with `ProhibitionIcon`:
5
+ * the 24px glyph has a much heavier stroke and looks wrong at illustration sizes.
6
+ */
7
+ export declare function ProhibitionLargeIcon(props: SVGProps<SVGSVGElement>): JSX.Element;
@@ -0,0 +1 @@
1
+ export * from './ProhibitionLargeIcon';
@@ -1,3 +1,3 @@
1
1
  import { SVGProps } from 'react';
2
- /** Material Symbols `stylus_note` glyph, exactly as used by the Ad Voca Figma (signed states). */
2
+ /** Design-system `stylus_note` glyph (24px grid), as used on the Ad Voca sign buttons. */
3
3
  export declare function StylusNoteIcon(props: SVGProps<SVGSVGElement>): JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { SVGProps } from 'react';
2
+ /**
3
+ * Thin-stroke `stylus_note` illustration on a 140px grid — the exact Ad Voca Figma export used
4
+ * inside the tinted result-page circle ("Document signed"). Not interchangeable with
5
+ * `StylusNoteIcon`: the 24px glyph has a much heavier stroke and looks wrong at illustration sizes.
6
+ */
7
+ export declare function StylusNoteLargeIcon(props: SVGProps<SVGSVGElement>): JSX.Element;
@@ -0,0 +1 @@
1
+ export * from './StylusNoteLargeIcon';
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "3.84.0",
6
+ "version": "3.86.0",
7
7
  "type": "module",
8
8
  "sideEffects": [
9
9
  "**/*.css",
@@ -47,6 +47,109 @@
47
47
  }
48
48
  }
49
49
 
50
+ /*
51
+ * ASMA-8210 (TB-02) — touch-readiness primitives for the Ad Voca mobile TabBar/Menu work.
52
+ *
53
+ * Four opt-in utilities. Every one of them is inert until a finger or a mouse button is on the
54
+ * control, or until the viewport is mobile, so no resting style changes anywhere: the frozen
55
+ * barrel (`api-surface.test.ts`) and the VRT baselines — captured at 1280x720 with a fine
56
+ * pointer — are untouched by design, not by luck. They reach consumers through `dist/style.css`,
57
+ * the same route as `.noselect` and `.absolute-center`, so no new export is needed to use them.
58
+ *
59
+ * Deliberately UNLAYERED (like the reduced-motion block above), not in `@layer utilities`:
60
+ * `tailwind.config.ts` sets `important: true`, and Tailwind v3 importantifies custom utilities
61
+ * inside `@layer utilities` too. That would turn `.asma-hit-area`'s `position: relative` into
62
+ * `!important`, making it unoverridable by Tailwind position utilities and by inline styles.
63
+ * Unlayered, these rules still sit after the `@tailwind utilities` output in source order, so
64
+ * the one `!important` we do want (`.asma-touch-target`, below) keeps beating `min-h-*`.
65
+ *
66
+ * Pick per control:
67
+ * .asma-touch-ready control that already has a designed `:active` (Button, Chip, Switch).
68
+ * .asma-pressable control with no pressed state at all (Tab, MenuItem, Link).
69
+ * .asma-hit-area isolated control that must reach 44px without growing.
70
+ * .asma-touch-target row in a stack, where growing on a phone is the honest fix.
71
+ *
72
+ * Sizes come from `--asma-touch-target-size` (44px, Apple HIG / WCAG 2.5.5 AAA; WCAG 2.5.8 AA
73
+ * asks only 24px, so this is the stricter of the two) and `--asma-pressed-opacity`. Both are
74
+ * plain custom properties: a component or a consumer retunes them without a new class.
75
+ */
76
+
77
+ /*
78
+ * Kills the grey iOS/Android tap flash and the 300ms double-tap-zoom delay that `touch-action:
79
+ * auto` implies. Both behaviours exist only for touch input, so a mouse or trackpad renders and
80
+ * behaves exactly as it did before.
81
+ */
82
+ .asma-touch-ready,
83
+ .asma-pressable {
84
+ -webkit-tap-highlight-color: transparent;
85
+ touch-action: manipulation;
86
+ }
87
+
88
+ /*
89
+ * The replacement for the tap flash removed above — without it, touch would end up with *less*
90
+ * feedback than before. Opacity is background-agnostic (filled, outlined and bare controls all
91
+ * read the same) and layout-neutral, so it never reflows and never reaches a VRT baseline.
92
+ * Disabled controls stay inert, matching their `onClick` being detached.
93
+ */
94
+ .asma-pressable:active:not(:disabled):not([aria-disabled='true']) {
95
+ opacity: var(--asma-pressed-opacity, 0.7);
96
+ }
97
+
98
+ /*
99
+ * A 44px pointer target on a control that must keep its drawn size: a transparent, centred
100
+ * overlay that belongs to the control, so a press anywhere inside it activates the control
101
+ * itself. `width/height: 100%` keeps it at least as large as the control it sits on.
102
+ *
103
+ * The supplied `position: relative` carries no `!important` (see the unlayered note above), so
104
+ * an inline `style` always overrides it, and a Tailwind position utility (importantified by
105
+ * `important: true`) does too.
106
+ *
107
+ * Caveats, all of them reasons to reach for `.asma-touch-target` instead:
108
+ * - it is NOT mobile-gated: the enlarged pointer target applies to desktop mouse users too
109
+ * (harmless for isolated controls, surprising next to other clickable things);
110
+ * - it owns the element's `::after`, clobbering any decorative `::after` the control has;
111
+ * - an ancestor `overflow: hidden` clips it, and a scroll container counts it as overflow;
112
+ * - it covers nested interactive children, and neighbours closer together than 44px would
113
+ * overlap and steal each other's presses.
114
+ */
115
+ .asma-hit-area {
116
+ position: relative;
117
+ }
118
+
119
+ .asma-hit-area::after {
120
+ content: '';
121
+ position: absolute;
122
+ left: 50%;
123
+ top: 50%;
124
+ width: 100%;
125
+ height: 100%;
126
+ min-width: var(--asma-touch-target-size, 44px);
127
+ min-height: var(--asma-touch-target-size, 44px);
128
+ transform: translate(-50%, -50%);
129
+ }
130
+
131
+ /*
132
+ * 44px tall rows in a stack, where overlapping hit areas would fight and growing is what the
133
+ * design actually wants. Gated to the mobile breakpoint (`_breakpoints.scss` `$mobile-max`) so
134
+ * tablet and desktop — including every VRT baseline — stay byte-identical. `!important` is
135
+ * required to outrank Tailwind's own `min-h-*`, which `important: true` emits with `!`.
136
+ *
137
+ * This is a LATER-SOURCE OVERRIDE, not a CSS `max()` floor — it replaces whatever `min-height`
138
+ * the component declares, it cannot clamp against it. Two consequences (ASMA-8210 review B1):
139
+ * - Only apply it where the EFFECTIVE min-height is below 44px — effective, not the class you
140
+ * can see: stacked utilities resolve by emitted order, so verify with computed style. On a
141
+ * component resting at `min-h-12` (48px) this rule would SHRINK the target — that is why
142
+ * StyledTab takes only `asma-pressable` (both tab variants effectively rest at 48px; even
143
+ * the small one, whose `min-h-10` loses to the base `min-h-12` by source order).
144
+ * - It deliberately sets no `min-width`: a width override would replace designed minimums
145
+ * like the tab's `min-w-[90px]` with 44px. Width floors stay per-component.
146
+ */
147
+ @media (max-width: 743px) {
148
+ .asma-touch-target {
149
+ min-height: var(--asma-touch-target-size, 44px) !important;
150
+ }
151
+ }
152
+
50
153
  @layer utilities {
51
154
  .flip-180 {
52
155
  transform: rotate(180deg);
@@ -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
+ })