Skip to content

Commit 33ef1f9

Browse files
Merge pull request #460 from viaacode/feature/ARC-3813_driekeuzespeler-modal-react-components
refactor(ARC-3813): move the driekeuzespeler modal to react-components
2 parents 550c030 + 8c2e129 commit 33ef1f9

5 files changed

Lines changed: 221 additions & 70 deletions

File tree

ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.scss

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,17 @@ $tile-hover-duration: 450ms;
7171
$tile-hover-easing: cubic-bezier(0.22, 1, 0.36, 1);
7272
$tile-rest-duration: 700ms;
7373

74-
// Taking over as the active tile runs in two stages: the tile steps outward, and only then swells
75-
// back into its place while the stack behind it reorders. The reference stack steps out by 60 of
76-
// its 950, over 190ms, and hops 12 up halfway through the swell.
77-
$tile-step-out-distance: math.div(60, $stack-width) * 100cqw; // 6.316cqw
74+
// Taking over as the active tile runs in two stages, quicker and sharper at first, then settling
75+
// in more slowly, and hops 12 up halfway through. A timing split only now, not a position -- see
76+
// the keyframe further down. ARC-3813
7877
$tile-step-out-duration: 270ms;
7978
$tile-step-out-easing: cubic-bezier(0.4, 0, 0.8, 0.4);
8079
$tile-settle-lift: 1.2rem;
8180
$tile-activate-duration: $tile-step-out-duration + $tile-rest-duration; // 690ms
8281
$tile-step-out-stop: math.div($tile-step-out-duration, $tile-activate-duration) * 100%; // 27.536%
82+
// Unitless, for the scale blend below: a plain fraction multiplies against a CSS calc() cleanly,
83+
// where a percentage would not.
84+
$tile-step-out-fraction: math.div($tile-step-out-duration, $tile-activate-duration);
8385
$tile-settle-stop: $tile-step-out-stop + 0.45 * (100% - $tile-step-out-stop); // 60.145%
8486

8587
// A border width, not spacing, so not a spacer multiple.
@@ -98,6 +100,16 @@ $pill-height-mobile: variables.$g-spacer-unit * 6;
98100
// breakpoint and 1440. The bounds are the mobile and desktop sizes of the design.
99101
$pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
100102

103+
// Registered so the browser treats it as a length, which is what makes it eligible for the
104+
// `transition` below: an unregistered custom property can only snap between values, never ease,
105+
// so a bystander tile losing its shift the instant it becomes the hovered one would otherwise
106+
// jump straight to 0 before the activate keyframe even reads it. ARC-3813
107+
@property --tile-shift {
108+
syntax: "<length>";
109+
inherits: true;
110+
initial-value: 0;
111+
}
112+
101113
.c-driekeuzespeler {
102114
position: relative;
103115

@@ -186,7 +198,6 @@ $pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
186198
&:nth-child(1) {
187199
--tile-tilt: #{-$tile-tilt};
188200
--tile-tilt-active: #{-$tile-active-tilt};
189-
--tile-step-out: calc(#{$tile-step-out-distance} * -1);
190201

191202
left: $hit-left-desktop;
192203
}
@@ -201,7 +212,6 @@ $pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
201212
&:nth-child(3) {
202213
--tile-tilt: #{$tile-tilt};
203214
--tile-tilt-active: #{$tile-active-tilt};
204-
--tile-step-out: #{$tile-step-out-distance};
205215

206216
left: $hit-left-desktop + 2 * $tile-offset-desktop;
207217
}
@@ -251,10 +261,12 @@ $pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
251261
background-color: var(--tile-color, #{colors.$color-gray-100});
252262
box-shadow: $tile-shadow-inactive;
253263

254-
// Transform and shadow only, so no layout work per frame.
264+
// Transform and shadow only, so no layout work per frame. `--tile-shift` rides along so it can
265+
// still be mid-transition when the activate keyframe below starts reading it.
255266
transition:
256267
transform $tile-rest-duration $tile-hover-easing,
257-
box-shadow $tile-rest-duration $tile-hover-easing;
268+
box-shadow $tile-rest-duration $tile-hover-easing,
269+
--tile-shift $tile-rest-duration $tile-hover-easing;
258270

259271
// The white band on the active tile. Always present, revealed only when active, so it does not
260272
// fade in halfway through the grow.
@@ -316,7 +328,7 @@ $pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
316328
}
317329
}
318330

319-
// The tile's tilt and scale on :focus-within is motion, not a focus indicator, and it is gone
331+
// The tile's tilt and scale on :focus-visible is motion, not a focus indicator, and it is gone
320332
// altogether under prefers-reduced-motion. The keyboard user needs a real outline, and it belongs
321333
// on the tile rather than on the zone the button fills, so it hugs what the visitor sees.
322334
&__tile:has(&__tile-button:focus-visible) &__tile-visual {
@@ -402,9 +414,18 @@ $pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
402414
}
403415
}
404416

405-
// Stage one steps the tile outward at its resting size, stage two swells it back into its place.
406-
// `--tile-scale` is deliberately not read here: the hover rule below sets it for the tile the
407-
// animation holds at the end, so reading it would start the first frame already grown.
417+
// Position moves only via `--tile-shift`. A tile that's already shifted as another tile's
418+
// bystander has its own settle under way on that same property; adding a fixed step-out motion on
419+
// top of it fought over the same axis and produced a visible reversal. One source of motion only,
420+
// so a tile only ever travels towards wherever it currently sits. ARC-3813
421+
422+
// The step-out stop still marks where the easing switches and where scale gets the blended value
423+
// below, rather than following one unbroken curve for the whole animation.
424+
425+
// The tiles overlap at rest (a fanned stack), so z-index cannot go active from frame one: an
426+
// inactive-sized tile would paint over its neighbour before any motion justifies the reorder.
427+
// z-index climbs gradually across this same stage too (the browser interpolates it numerically,
428+
// the same as any other property), so it never gets ahead of the tile's actual position.
408429
@keyframes c-driekeuzespeler-tile-activate {
409430
0% {
410431
z-index: $tile-z-inactive;
@@ -416,8 +437,11 @@ $pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
416437
#{$tile-step-out-stop} {
417438
// The stack only reorders once the tile is clear of it.
418439
z-index: $tile-z-active;
419-
transform: translate(calc(-50% + var(--tile-shift, 0px) + var(--tile-step-out, 0px)), -50%)
420-
rotate(var(--tile-tilt, 0deg)) scale($tile-scale-inactive);
440+
transform: translate(calc(-50% + var(--tile-shift, 0px)), -50%)
441+
rotate(var(--tile-tilt, 0deg))
442+
scale(
443+
calc(#{$tile-scale-inactive} + #{$tile-step-out-fraction} * (var(--tile-scale-active) - #{$tile-scale-inactive}))
444+
);
421445
animation-timing-function: $tile-hover-easing;
422446
}
423447

@@ -449,24 +473,36 @@ $pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
449473
// Every rule below is triggered by the hover zone and lands on the visual inside it. Splitting the
450474
// two is what stops the jitter: the zone the pointer tests against never moves.
451475

476+
// `:focus-within` would also stay true after the modal closes: react-modal returns focus to the
477+
// tile that opened it, and a mouse-driven return leaves the tile looking stuck in its hover pose
478+
// while the visitor has moved on. `:focus-visible` only holds the pose for a focus a keyboard
479+
// visitor can see, which is what a returned focus is unless Escape (not the mouse) closed the modal.
480+
// Written out rather than through `:is()`: `:has()` cannot appear inside `:is()`, so every rule
481+
// below spells out the hover and the focus-visible branch as separate, comma-joined selectors.
482+
452483
// No hover or focus anywhere in the stack: the middle tile is the active one. `:has` drives the
453484
// siblings with no javascript, as BlockHeroCarousel does.
454-
.c-driekeuzespeler__tiles:not(:has(.c-driekeuzespeler__tile:is(:hover, :focus-within)))
485+
.c-driekeuzespeler__tiles:not(
486+
:has(.c-driekeuzespeler__tile:hover, .c-driekeuzespeler__tile .c-driekeuzespeler__tile-button:focus-visible)
487+
)
455488
.c-driekeuzespeler__tile:nth-child(2)
456489
.c-driekeuzespeler__tile-visual {
457490
@include tile-active;
458491
}
459492

460493
// A hovered tile takes over the active look whole, so the stack always has one tile at that size.
461-
.c-driekeuzespeler__tile:is(:hover, :focus-within) .c-driekeuzespeler__tile-visual {
494+
.c-driekeuzespeler__tile:hover .c-driekeuzespeler__tile-visual,
495+
.c-driekeuzespeler__tile:has(:focus-visible) .c-driekeuzespeler__tile-visual {
462496
@include tile-active;
463497
}
464498

465499
// Only a side tile travels: the middle one is already the active tile, and hovering it moves the
466500
// two beside it instead. `forwards`, so the tile holds the last frame until the pointer leaves --
467501
// the transform transition then carries it back to rest on its own.
468-
.c-driekeuzespeler__tile:nth-child(1):is(:hover, :focus-within) .c-driekeuzespeler__tile-visual,
469-
.c-driekeuzespeler__tile:nth-child(3):is(:hover, :focus-within) .c-driekeuzespeler__tile-visual {
502+
.c-driekeuzespeler__tile:nth-child(1):hover .c-driekeuzespeler__tile-visual,
503+
.c-driekeuzespeler__tile:nth-child(1):has(:focus-visible) .c-driekeuzespeler__tile-visual,
504+
.c-driekeuzespeler__tile:nth-child(3):hover .c-driekeuzespeler__tile-visual,
505+
.c-driekeuzespeler__tile:nth-child(3):has(:focus-visible) .c-driekeuzespeler__tile-visual {
470506
animation:
471507
c-driekeuzespeler-tile-activate $tile-activate-duration linear forwards,
472508
c-driekeuzespeler-tile-settle $tile-activate-duration linear;
@@ -475,7 +511,10 @@ $pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
475511
// The stack makes room for the hovered tile: its neighbour steps aside by a nudge, and the tile on
476512
// the far side slides inward and drops behind the middle one. Delayed by the first stage, so the
477513
// stack waits for the hovered tile to clear it.
478-
.c-driekeuzespeler__tiles:has(.c-driekeuzespeler__tile:nth-child(1):is(:hover, :focus-within)) {
514+
.c-driekeuzespeler__tiles:has(
515+
.c-driekeuzespeler__tile:nth-child(1):hover,
516+
.c-driekeuzespeler__tile:nth-child(1) .c-driekeuzespeler__tile-button:focus-visible
517+
) {
479518
.c-driekeuzespeler__tile:nth-child(2) .c-driekeuzespeler__tile-visual {
480519
--tile-shift: var(--tile-nudge);
481520

@@ -490,7 +529,10 @@ $pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
490529
}
491530
}
492531

493-
.c-driekeuzespeler__tiles:has(.c-driekeuzespeler__tile:nth-child(3):is(:hover, :focus-within)) {
532+
.c-driekeuzespeler__tiles:has(
533+
.c-driekeuzespeler__tile:nth-child(3):hover,
534+
.c-driekeuzespeler__tile:nth-child(3) .c-driekeuzespeler__tile-button:focus-visible
535+
) {
494536
.c-driekeuzespeler__tile:nth-child(2) .c-driekeuzespeler__tile-visual {
495537
--tile-shift: calc(var(--tile-nudge) * -1);
496538

@@ -507,7 +549,10 @@ $pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem);
507549

508550
// The middle tile is the active one at rest, so hovering it grows nothing. Both side tiles give it
509551
// room instead, which is the only thing that moves.
510-
.c-driekeuzespeler__tiles:has(.c-driekeuzespeler__tile:nth-child(2):is(:hover, :focus-within)) {
552+
.c-driekeuzespeler__tiles:has(
553+
.c-driekeuzespeler__tile:nth-child(2):hover,
554+
.c-driekeuzespeler__tile:nth-child(2) .c-driekeuzespeler__tile-button:focus-visible
555+
) {
511556
.c-driekeuzespeler__tile:nth-child(1) .c-driekeuzespeler__tile-visual {
512557
--tile-shift: calc(var(--tile-push) * -1);
513558
}

ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,10 @@ export const BlockDriekeuzespeler: FunctionComponent<BlockDriekeuzespelerProps>
6868
// An index rather than the interest itself, so it survives the renderer handing us a rebuilt array.
6969
const [openedIndex, setOpenedIndex] = useState<number | null>(null);
7070

71-
// The tile a pointer opened the modal from. Escape closes the modal without moving focus, and the
72-
// stack's pose follows :focus-within, so that tile would stay in its opened pose under a cursor
73-
// that sits somewhere else. Null after a keyboard open: there the focus belongs to the visitor.
71+
// The tile a pointer opened the modal from. react-modal returns focus to it on close by any
72+
// method, and the stack's pose follows :focus-visible, so that returned focus would otherwise
73+
// leave the tile looking hovered under a cursor that has moved on. Null after a keyboard open:
74+
// there the focus already belongs to the visitor, and blurring it would fight their own tabbing.
7475
const openerRef = useRef<HTMLButtonElement | null>(null);
7576

7677
// Only the three on screen are resolved, not all two hundred a block may hold. The pids are part
@@ -212,8 +213,13 @@ export const BlockDriekeuzespeler: FunctionComponent<BlockDriekeuzespelerProps>
212213
isFetching={isFetchingObjects || isFetchingPlayableFile}
213214
onClose={() => {
214215
setOpenedIndex(null);
215-
openerRef.current?.blur();
216+
217+
// react-modal only returns focus to the opener once its own close effect runs, which is
218+
// after this handler -- blurring here would just be undone a moment later. Deferred a
219+
// tick so it lands after that return.
220+
const opener = openerRef.current;
216221
openerRef.current = null;
222+
setTimeout(() => opener?.blur(), 0);
217223
}}
218224
/>
219225
</div>

ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.scss

Lines changed: 106 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,112 @@
11
@use "../../../../shared/styles/settings/colors" as colors;
22
@use "../../../../shared/styles/settings/variables" as variables;
33

4-
$card-radius: variables.$g-spacer-unit * 1.5;
4+
// Matches the shuffle button's own hover shade (hetarchief-client's `$shade`): admin-core can't
5+
// reach that client-side variable, so the value is mirrored here rather than shared.
6+
$close-hover-shade: #505050;
7+
8+
// Modal chrome
9+
// ---
10+
// `Modal` from @meemoo/react-components ships no styles of its own, so the look that used to come
11+
// from avo's `size="extra-large"`, `scrollable` and `borderless` props lives here instead. Scoped
12+
// to this block, so no other modal in the app is touched.
13+
14+
// `Modal`'s default root class is the generic `c-modal`, which avo2-components still styles
15+
// globally for other modals in the app (background, width, radius, shadow, its own z-index).
16+
// `rootClassName` below replaces it outright, so this modal never inherits or collides with that.
17+
.c-driekeuzespeler-modal__overlay {
18+
position: fixed;
19+
inset: 0;
20+
z-index: 50;
21+
display: flex;
22+
padding: variables.$g-spacer-unit * 3;
23+
24+
// A takeover of whatever it shows reads darker than a dialog floating over the page.
25+
background-color: rgba(colors.$color-gray-shadow, 0.96);
26+
27+
@media (max-width: variables.$g-bp2) {
28+
padding: variables.$g-spacer-unit * 2.5;
29+
}
30+
}
531

632
.c-driekeuzespeler-modal {
33+
position: relative;
34+
display: flex;
35+
flex-direction: column;
36+
width: 140rem;
37+
max-height: 100%;
38+
margin: auto;
39+
background-color: colors.$white;
40+
41+
@media (max-width: variables.$g-bp2) {
42+
width: 100%;
43+
}
44+
45+
// react-modal focuses the dialog itself on open, and the app paints a focus ring on whatever has
46+
// focus. This element is never reached by keyboard, so the ring is noise around the whole modal.
47+
&:focus {
48+
outline: none;
49+
}
50+
51+
// No title bar: the heading floats over the body rather than reserving a row above it, so the
52+
// media reaches the top edge of the modal. Only the close button shows.
53+
&__heading {
54+
position: absolute;
55+
top: 0;
56+
right: 0;
57+
z-index: 1;
58+
background-color: transparent;
59+
}
60+
61+
// Kept in the markup for the dialog's accessible name, never drawn.
62+
&__title-wrapper {
63+
position: absolute;
64+
width: 1px;
65+
height: 1px;
66+
margin: -1px;
67+
padding: 0;
68+
overflow: hidden;
69+
clip-path: inset(50%);
70+
white-space: nowrap;
71+
}
72+
73+
// A square tile in the corner, over the media. Compounded onto both of `Button`'s own classes for
74+
// higher specificity: its base rules (rounded, sized, teal) would otherwise tie this on
75+
// specificity and have to be out-raced on source order alone.
76+
&__close.c-button.c-button--icon {
77+
display: flex;
78+
align-items: center;
79+
justify-content: center;
80+
width: variables.$g-spacer-unit * 8;
81+
height: variables.$g-spacer-unit * 8;
82+
padding: 0;
83+
border: 0;
84+
border-radius: 0;
85+
background-color: colors.$color-gray-1000;
86+
color: colors.$white;
87+
cursor: pointer;
88+
transition: background-color 0.2s ease;
89+
90+
&:hover {
91+
background-color: $close-hover-shade;
92+
}
93+
94+
.o-svg-icon svg path {
95+
fill: currentcolor;
96+
}
97+
}
98+
99+
// The media runs edge to edge, so the body carries no padding of its own. Its own stacking
100+
// context, so nothing the player draws inside it (flowplayer's own overlays use z-index too) can
101+
// ever paint above the close button in `&__heading`, no matter what value it picks.
102+
&__content {
103+
position: relative;
104+
z-index: 0;
105+
flex: 1 1 auto;
106+
overflow-y: auto;
107+
padding: 0;
108+
}
109+
7110
&__media {
8111
display: flex;
9112
align-items: center;
@@ -77,14 +180,10 @@ $card-radius: variables.$g-spacer-unit * 1.5;
77180
font-weight: 700;
78181
}
79182

80-
// Mobile insets the media and stacks the metadata card under it, with the theme CTA on its own row.
183+
// Mobile keeps the same flush media, and stacks the theme CTA under the metadata.
81184
@media (max-width: variables.$g-bp2) {
82185
&__media {
83-
width: calc(100% - #{variables.$g-spacer-unit * 5});
84-
margin: 0 auto;
85-
border-radius: $card-radius $card-radius 0 0;
86-
87-
// Tighter than desktop's 70vh: the stacked metadata card needs the room.
186+
// Tighter than desktop's 70vh: the stacked metadata needs the room.
88187
max-height: 45vh;
89188
flex: 0 0 auto;
90189
}
@@ -95,9 +194,6 @@ $card-radius: variables.$g-spacer-unit * 1.5;
95194
flex-direction: column;
96195
align-items: stretch;
97196
background-color: colors.$white;
98-
border-radius: 0 0 $card-radius $card-radius;
99-
width: calc(100% - #{variables.$g-spacer-unit * 5});
100-
margin: 0 auto;
101197
}
102198

103199
&__divider {

0 commit comments

Comments
 (0)