Skip to content

Commit 396aeb1

Browse files
tleperougederer
andauthored
feat: add carousel component (#280)
* feat(component): add carousel component Adds a carousel component using a hook for maximum headlessness. 124 * feat: export Carousel to headless index * docs: add Carousel to docs * fix: add unique keys * docs: update docs of Carousel * feat: make Carousel a11y frierndly * fix: relocate use-methods into ts files * feat: integrate Carousel with child components * docs: update Carousel with child components pattern * feat(Carousel): clean up css * feat: improve aria keyboard navigation * feat: pass axe tests * feat: improve styling & keyboard supports * docs: add parts of Carousel * feat: add aria-current to Carousel Item * feat: remove control prop * feat: support state using useCarousel * feat: add presentation role to wrapper elements * feat: auto useCarousel when not defined * feat: improve support of auto carousel * feat: clean up carousel public API * feat: shorten the style * docs: add netflix example * feat: clean up unused files * feat: move styles into style dir * feat: reloacte CarouselContext * feat: improve netflix example * feat: simplified carousel api * docs: update docs and example --------- Co-authored-by: Greg Ederer <[email protected]>
1 parent c3e732e commit 396aeb1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2794
-401
lines changed

apps/website/src/global.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,6 @@ body {
8282
}
8383

8484
.layout {
85-
grid-template-columns: 25% auto;
85+
grid-template-columns: 25% 70%;
8686
grid-template-areas: 'sidebar content';
8787
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
h1 {
2+
margin: 2rem 0;
3+
padding-top: 1rem;
4+
font-weight: bold;
5+
border-top: 1px dotted #222;
6+
}
7+
8+
h2 {
9+
margin-block: 1.15em 0.5em;
10+
font-size: xx-large;
11+
}
12+
13+
h3 {
14+
margin-block: 0.85em 0.35em;
15+
font-size: x-large;
16+
}
17+
18+
hr {
19+
margin-block: 2em;
20+
}
21+
22+
.form-item {
23+
width: 35em;
24+
}
25+
26+
.outter {
27+
display: grid;
28+
}
29+
30+
.inner {
31+
display: flex;
32+
align-items: center;
33+
}
34+
35+
.controls {
36+
padding: 2em;
37+
margin-inline: auto;
38+
display: flex;
39+
justify-content: center;
40+
gap: 0.5em;
41+
}
42+
43+
.control {
44+
width: 2em;
45+
aspect-ratio: 1/1;
46+
border-radius: 50%;
47+
display: flex;
48+
align-items: center;
49+
justify-content: center;
50+
transition: all 0.3s 0.1s ease-out;
51+
cursor: pointer;
52+
}
53+
54+
.control[aria-current='true'] {
55+
font-weight: 900;
56+
}
57+
58+
.img {
59+
height: 500px;
60+
object-fit: cover;
61+
}
62+
63+
.item {
64+
width: 350px;
65+
}
66+
67+
.item:nth-child(8),
68+
.item:nth-child(6),
69+
.item:nth-child(2) {
70+
width: 100px;
71+
}
72+
73+
.item:nth-child(5),
74+
.item:nth-child(3) {
75+
width: 150px;
76+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/* carousel */
2+
3+
.qui-carousel {
4+
align-items: start;
5+
}
6+
7+
/* card */
8+
9+
.carousel__card {
10+
--card-width: max(min(25em, 20vw), 250px);
11+
--card-border-radius: 1em;
12+
--card-outline-color: transparent;
13+
--card-outline: 0.15em solid;
14+
--card-outline-offset: 0.1em;
15+
--card-margin: 2em;
16+
--card-width-shadow: 1em;
17+
--transition-ms: 150ms;
18+
19+
--button-border-radius: 0.35em;
20+
--button-background-color: #19b6f6;
21+
}
22+
23+
.carousel__card {
24+
width: var(--card-width);
25+
align-items: start;
26+
height: max-content;
27+
border-radius: var(--card-border-radius);
28+
outline-color: var(--card-outline-color);
29+
transition: margin calc(var(--transition-ms) * 1.5) ease-out;
30+
}
31+
32+
article > :first-child {
33+
border-top-left-radius: var(--card-border-radius);
34+
border-top-right-radius: var(--card-border-radius);
35+
}
36+
37+
article > :last-child {
38+
border-bottom-left-radius: var(--card-border-radius);
39+
border-bottom-right-radius: var(--card-border-radius);
40+
}
41+
42+
article {
43+
position: relative;
44+
left: 0;
45+
right: 0;
46+
margin: calc(var(--card-margin) + var(--card-width-shadow))
47+
calc(var(--card-margin) / 4);
48+
border-radius: var(--card-border-radius);
49+
transition: margin calc(var(--transition-ms) * 1.5) 100ms ease-out,
50+
box-shadow calc(var(--transition-ms) * 2.5) ease-out;
51+
overflow: hidden;
52+
}
53+
54+
/* card content */
55+
56+
img {
57+
border-bottom-left-radius: var(--card-border-radius);
58+
border-bottom-right-radius: var(--card-border-radius);
59+
transition: border-radius calc(var(--transition-ms) * 0.75) ease-in-out;
60+
}
61+
62+
.inner {
63+
display: grid;
64+
row-gap: 0.5em;
65+
background-color: black;
66+
padding: 1em;
67+
transition: opacity calc(var(--transition-ms) * 1.5) ease-out;
68+
opacity: 0;
69+
height: 0;
70+
position: absolute;
71+
}
72+
73+
.inner h3 {
74+
font-size: x-large;
75+
font-weight: bolder;
76+
margin-block: 1em;
77+
line-height: 1;
78+
}
79+
80+
.inner a {
81+
padding: 0.75em 1.35em;
82+
background-color: var(--button-background-color);
83+
border-radius: var(--button-border-radius);
84+
color: black;
85+
margin-inline-start: auto;
86+
margin-block: 1em;
87+
}
88+
89+
/* state */
90+
91+
.carousel__card:focus-within article {
92+
outline: var(--card-outline);
93+
outline-offset: var(--card-outline-offset);
94+
}
95+
96+
.carousel__card[aria-current='true'] {
97+
--card-margin: -2em;
98+
}
99+
100+
.carousel__card[aria-current='true']:first-child article {
101+
margin-inline-start: var(--card-width-shadow);
102+
margin-inline-end: calc(2 * var(--card-margin) - var(--card-width-shadow));
103+
}
104+
105+
.carousel__card[aria-current='true']:last-child article {
106+
margin-inline-start: calc(2 * var(--card-margin) - var(--card-width-shadow));
107+
margin-inline-end: var(--card-width-shadow);
108+
}
109+
110+
.carousel__card[aria-current='true'] article {
111+
box-shadow: 0 0 var(--card-width-shadow) rgba(0, 0, 0, 0.75),
112+
0 0.375em 0.375em rgba(0, 0, 0, 0.5);
113+
margin-block: var(--card-width-shadow);
114+
margin-inline: var(--card-margin);
115+
}
116+
117+
.carousel__card[aria-current='true'] img {
118+
border-bottom-left-radius: 0;
119+
border-bottom-right-radius: 0;
120+
}
121+
122+
.carousel__card[aria-current='true'] .inner {
123+
height: auto;
124+
position: relative;
125+
opacity: 1;
126+
}
127+
128+
/* page layout */
129+
130+
header h2 {
131+
font-size: x-large;
132+
flex-grow: 1;
133+
}
134+
135+
header,
136+
nav {
137+
display: flex;
138+
align-items: center;
139+
}
140+
141+
header {
142+
margin-block-start: 2em;
143+
}
144+
145+
hr {
146+
margin-block: 0.5em 2em;
147+
opacity: 0.25;
148+
}
149+
150+
/* navigation */
151+
152+
button[disabled] {
153+
opacity: 0.35;
154+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {
2+
$,
3+
QwikIntrinsicElements,
4+
component$,
5+
useId,
6+
useSignal,
7+
useStylesScoped$,
8+
} from '@builder.io/qwik';
9+
import { Carousel, useCarousel, CarouselContext } from '@qwik-ui/headless';
10+
import styles from './example-netflix.css?inline';
11+
12+
const {
13+
Item,
14+
Items,
15+
Root,
16+
IconNext,
17+
IconPrevious,
18+
IconChevronLeft,
19+
IconChevronRight,
20+
} = Carousel;
21+
22+
const media = {
23+
title: 'Qwik Workshop - Live Coding',
24+
rating: 100,
25+
banner: 'https://i.ytimg.com/vi/GHbNaDSWUX8/maxresdefault.jpg',
26+
description: 'Lorem ipsum',
27+
};
28+
29+
export const ExampleNetflix = component$(() => {
30+
const { scopeId } = useStylesScoped$(styles);
31+
const items = useSignal([...new Array(10)]);
32+
const carousel = useCarousel({ loop: false, transition: 350, startAt: 3 });
33+
34+
return (
35+
<>
36+
<header>
37+
<h2>Netflix example</h2>
38+
<Navigation carousel={carousel} class={scopeId} />
39+
</header>
40+
41+
<hr />
42+
43+
<Root use={carousel}>
44+
<Items class={[scopeId, 'qui-carousel']}>
45+
{items.value.map((_, i) => (
46+
<Item
47+
key={useId()}
48+
index={i}
49+
label={media.title}
50+
class={[scopeId, 'carousel__card']}
51+
onClick$={() => carousel.items.scrollAt(i)}
52+
>
53+
<article>
54+
<img src={media.banner} alt="img" />
55+
<div class="inner" role="presentation">
56+
<h3>{media.title}</h3>
57+
<div class="rate">{`${media.rating}% 👍`}</div>
58+
<p>{media.description}</p>
59+
<a href="#">Read more</a>
60+
</div>
61+
</article>
62+
</Item>
63+
))}
64+
</Items>
65+
</Root>
66+
</>
67+
);
68+
});
69+
70+
type NavigationProps = QwikIntrinsicElements['nav'] & {
71+
carousel: CarouselContext;
72+
};
73+
74+
export const Navigation = component$(
75+
({ carousel, ...props }: NavigationProps) => {
76+
const previous = $(() => {
77+
if (!carousel.loop && carousel.items.active.isFirst.value) {
78+
return;
79+
}
80+
carousel.items.previous();
81+
});
82+
83+
const next = $(() => {
84+
if (!carousel.loop && carousel.items.active.isLast.value) {
85+
return;
86+
}
87+
carousel.items.next();
88+
});
89+
90+
return (
91+
<nav {...props}>
92+
{carousel.items.active.current.value + 1} / {carousel.items.total.value}{' '}
93+
total
94+
<button
95+
class={props.class}
96+
disabled={!carousel.loop && carousel.items.active.isFirst.value}
97+
onClick$={carousel.pages.previous}
98+
>
99+
<IconChevronLeft />
100+
</button>
101+
<button
102+
class={props.class}
103+
disabled={!carousel.loop && carousel.items.active.isFirst.value}
104+
onClick$={previous}
105+
>
106+
<IconPrevious />
107+
</button>
108+
{carousel.items.visible.first.value + 1}-
109+
{carousel.items.visible.last.value + 1}
110+
<button
111+
class={props.class}
112+
disabled={!carousel.loop && carousel.items.active.isLast.value}
113+
onClick$={next}
114+
>
115+
<IconNext />
116+
</button>
117+
<button
118+
class={props.class}
119+
disabled={!carousel.loop && carousel.items.active.isLast.value}
120+
onClick$={carousel.pages.next}
121+
>
122+
<IconChevronRight />
123+
</button>
124+
</nav>
125+
);
126+
}
127+
);

0 commit comments

Comments
 (0)