From d2581896653b6291f5b1ed97a6802069f2b7ddc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Lep=C3=A9rou?= Date: Sat, 8 Apr 2023 18:10:01 +1000 Subject: [PATCH] feat(component): add carousel component (#258) * 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 --------- Co-authored-by: Greg Ederer Co-authored-by: Giorgio Boa <35845425+gioboa@users.noreply.github.com> --- apps/website/src/components/menu/menu.tsx | 4 + .../routes/docs/headless/carousel/index.tsx | 137 ++++++ .../src/components/carousel/carousel.axe.json | 446 ++++++++++++++++++ .../src/components/carousel/carousel.tsx | 222 +++++++++ .../components/carousel/styles-buttons.css | 3 + .../components/carousel/styles-control.css | 8 + .../src/components/carousel/styles-item.css | 20 + .../src/components/carousel/styles-items.css | 49 ++ .../src/components/carousel/use-carousel.ts | 130 +++++ .../src/components/carousel/use-ordinal.ts | 14 + packages/headless/src/index.ts | 1 + 11 files changed, 1034 insertions(+) create mode 100644 apps/website/src/routes/docs/headless/carousel/index.tsx create mode 100644 packages/headless/src/components/carousel/carousel.axe.json create mode 100644 packages/headless/src/components/carousel/carousel.tsx create mode 100644 packages/headless/src/components/carousel/styles-buttons.css create mode 100644 packages/headless/src/components/carousel/styles-control.css create mode 100644 packages/headless/src/components/carousel/styles-item.css create mode 100644 packages/headless/src/components/carousel/styles-items.css create mode 100644 packages/headless/src/components/carousel/use-carousel.ts create mode 100644 packages/headless/src/components/carousel/use-ordinal.ts diff --git a/apps/website/src/components/menu/menu.tsx b/apps/website/src/components/menu/menu.tsx index 2b438e14f..145f0c4cf 100644 --- a/apps/website/src/components/menu/menu.tsx +++ b/apps/website/src/components/menu/menu.tsx @@ -37,6 +37,10 @@ export const Menu = component$(({ onClose$ }) => { path: `/docs/${appState.theme.toLowerCase()}/button-group`, }, { label: 'Card', path: `/docs/${appState.theme.toLowerCase()}/card` }, + { + label: 'Carousel', + path: `/docs/${appState.theme.toLowerCase()}/carousel`, + }, { label: 'Collapse', path: `/docs/${appState.theme.toLowerCase()}/collapse`, diff --git a/apps/website/src/routes/docs/headless/carousel/index.tsx b/apps/website/src/routes/docs/headless/carousel/index.tsx new file mode 100644 index 000000000..e9af9a6eb --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/index.tsx @@ -0,0 +1,137 @@ +import { + component$, + useId, + useSignal, + useStylesScoped$, +} from '@builder.io/qwik'; +import { Carousel } from '@qwik-ui/headless'; + +const { + Controls, + Control, + Item, + Items, + Root, + ButtonNext, + ButtonPrevious, + IconNext, + IconPrevious, +} = Carousel; + +export const ITEMS: { src: string; title: string }[] = Array.from({ + length: 4, +}).map(() => ({ + src: 'https://picsum.photos/1200/550', + title: 'My great image', +})); + +export default component$(() => { + const { scopeId } = useStylesScoped$(` + h1 { margin: 2rem 0; padding-top: 1rem; font-weight: bold; border-top: 1px dotted #222} + h2 { margin-block: 1.15em 0.5em; font-size: xx-large; } + h3 { margin-block: 0.85em 0.35em; font-size: x-large; } + hr { margin-block: 2em; } + + .form-item, hr { width: 35em; } + + .outter { + display: grid; + } + + .inner { + display: flex; + align-items: center; + } + + .controls { + padding: 2em; + margin-inline: auto; + display: flex; + justify-content: center; + gap: 0.5em; + } + + .control { + width: 2em; + aspect-ratio: 1/1; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all .3s .1s ease-out; + cursor: pointer; + } + + .control[aria-current="true"] { + font-weight: 900; + } + + .item { + height: 500px; + width: 100%; + object-fit: cover; + } + `); + + const items = useSignal(ITEMS); + + return ( + <> +

This is the documentation for the Carousel

+ +

Carousel Example

+ + +
+ + + + + {items.value.map(({ src, title }, i) => ( + + + + ))} + + + + +
+ + {items.value.map((_, i) => ( + + {i + 1} + + ))} + +
+ +
+ +

Inputs

+ +
    +
  • startAt: number, default 0
  • +
  • loop: boolean, default true
  • +
+ +

Parts

+ +
    +
  • Root
  • +
  • Items & Item
  • +
  • ButtonPrevious
  • +
  • ButtonNext
  • +
  • Controls & Control
  • +
+ +
+ +

Outputs

+ +
    +
  • +
+ + ); +}); diff --git a/packages/headless/src/components/carousel/carousel.axe.json b/packages/headless/src/components/carousel/carousel.axe.json new file mode 100644 index 000000000..d9c49c059 --- /dev/null +++ b/packages/headless/src/components/carousel/carousel.axe.json @@ -0,0 +1,446 @@ +{ + "url": "http://localhost:5173/docs/headless/carousel/", + "extensionVersion": "4.52.0", + "axeVersion": "4.6.3", + "standard": "WCAG 2.1 AA", + "testingStartDate": "2023-03-26T05:51:50.476Z", + "testingEndDate": "2023-03-26T06:11:53.150Z", + "bestPracticesEnabled": true, + "issueSummary": { + "critical": 4, + "moderate": 11, + "minor": 0, + "serious": 0, + "bestPractices": 11, + "needsReview": 0 + }, + "remainingTestingSummary": { + "run": false + }, + "igtSummary": [ + { + "tool": "Table", + "skipped": false, + "name": null, + "run": false + }, + { + "tool": "Keyboard", + "skipped": false, + "name": "3/26/2023 at 5:10 PM", + "run": true, + "issues": { + "critical": 0, + "moderate": 0, + "minor": 0, + "serious": 0 + }, + "duration": 20270 + }, + { + "tool": "Modal Dialog", + "skipped": false, + "name": null, + "run": false + }, + { + "tool": "Interactive Elements", + "skipped": false, + "name": "3/26/2023 at 5:11 PM", + "run": true, + "issues": { + "critical": 0, + "moderate": 0, + "minor": 0, + "serious": 0 + }, + "duration": 34179 + }, + { + "tool": "Structure", + "skipped": false, + "name": null, + "run": false + }, + { + "tool": "Images", + "skipped": false, + "name": null, + "run": false + }, + { + "tool": "Forms", + "skipped": false, + "name": null, + "run": false + } + ], + "failedRules": [ + { + "name": "landmark-one-main", + "count": 1, + "mode": "automated" + }, + { + "name": "region", + "count": 9, + "mode": "automated" + }, + { + "name": "page-has-heading-one", + "count": 1, + "mode": "automated" + }, + { + "name": "image-alt", + "count": 4, + "mode": "automated" + } + ], + "needsReview": [], + "allIssues": [ + { + "ruleId": "landmark-one-main", + "description": "Ensures the document has a main landmark", + "help": "Document should have one main landmark", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/landmark-one-main?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": ["html"], + "summary": "Fix all of the following:\n Document does not have a main landmark", + "source": "", + "tags": ["cat.semantics", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "region", + "description": "Ensures all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/region?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": ["body > p"], + "summary": "Fix any of the following:\n Some page content is not contained by landmarks", + "source": "

This is the documentation for the Carousel

", + "tags": ["cat.keyboard", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "region", + "description": "Ensures all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/region?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": ["h2"], + "summary": "Fix any of the following:\n Some page content is not contained by landmarks", + "source": "

Carousel Example

", + "tags": ["cat.keyboard", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "page-has-heading-one", + "description": "Ensure that the page, or at least one of its frames contains a level-one heading", + "help": "Page should contain a level-one heading", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/page-has-heading-one?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": ["html"], + "summary": "Fix all of the following:\n Page must have a level-one heading", + "source": "", + "tags": ["cat.semantics", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "image-alt", + "description": "Ensures elements have alternate text or a role of none or presentation", + "help": "Images must have alternate text", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/image-alt?application=AxeChrome", + "impact": "critical", + "needsReview": false, + "isManual": false, + "selector": [".⭐️we6d37-0:nth-child(1) > img"], + "summary": "Fix any of the following:\n Element does not have an alt attribute\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Element has no title attribute\n Element's default semantics were not overridden with role=\"none\" or role=\"presentation\"", + "source": "", + "tags": [ + "cat.text-alternatives", + "wcag2a", + "wcag111", + "section508", + "section508.22.a", + "ACT" + ], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "image-alt", + "description": "Ensures elements have alternate text or a role of none or presentation", + "help": "Images must have alternate text", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/image-alt?application=AxeChrome", + "impact": "critical", + "needsReview": false, + "isManual": false, + "selector": [".⭐️we6d37-0:nth-child(2) > img"], + "summary": "Fix any of the following:\n Element does not have an alt attribute\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Element has no title attribute\n Element's default semantics were not overridden with role=\"none\" or role=\"presentation\"", + "source": "", + "tags": [ + "cat.text-alternatives", + "wcag2a", + "wcag111", + "section508", + "section508.22.a", + "ACT" + ], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "image-alt", + "description": "Ensures elements have alternate text or a role of none or presentation", + "help": "Images must have alternate text", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/image-alt?application=AxeChrome", + "impact": "critical", + "needsReview": false, + "isManual": false, + "selector": [".⭐️we6d37-0:nth-child(3) > img"], + "summary": "Fix any of the following:\n Element does not have an alt attribute\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Element has no title attribute\n Element's default semantics were not overridden with role=\"none\" or role=\"presentation\"", + "source": "", + "tags": [ + "cat.text-alternatives", + "wcag2a", + "wcag111", + "section508", + "section508.22.a", + "ACT" + ], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "image-alt", + "description": "Ensures elements have alternate text or a role of none or presentation", + "help": "Images must have alternate text", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/image-alt?application=AxeChrome", + "impact": "critical", + "needsReview": false, + "isManual": false, + "selector": [".⭐️we6d37-0:nth-child(4) > img"], + "summary": "Fix any of the following:\n Element does not have an alt attribute\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Element has no title attribute\n Element's default semantics were not overridden with role=\"none\" or role=\"presentation\"", + "source": "", + "tags": [ + "cat.text-alternatives", + "wcag2a", + "wcag111", + "section508", + "section508.22.a", + "ACT" + ], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "region", + "description": "Ensures all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/region?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": ["ul:nth-child(3)"], + "summary": "Fix any of the following:\n Some page content is not contained by landmarks", + "source": "
    ", + "tags": ["cat.keyboard", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "region", + "description": "Ensures all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/region?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": [".carousel"], + "summary": "Fix any of the following:\n Some page content is not contained by landmarks", + "source": "
    ", + "tags": ["cat.keyboard", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "region", + "description": "Ensures all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/region?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": ["h3:nth-child(6)"], + "summary": "Fix any of the following:\n Some page content is not contained by landmarks", + "source": "

    Inputs

    ", + "tags": ["cat.keyboard", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "region", + "description": "Ensures all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/region?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": ["ul:nth-child(7)"], + "summary": "Fix any of the following:\n Some page content is not contained by landmarks", + "source": "
      ", + "tags": ["cat.keyboard", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "region", + "description": "Ensures all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/region?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": ["h3:nth-child(8)"], + "summary": "Fix any of the following:\n Some page content is not contained by landmarks", + "source": "

      q:slot

      ", + "tags": ["cat.keyboard", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "region", + "description": "Ensures all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/region?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": ["ul:nth-child(9)"], + "summary": "Fix any of the following:\n Some page content is not contained by landmarks", + "source": "
        ", + "tags": ["cat.keyboard", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + }, + { + "ruleId": "region", + "description": "Ensures all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.6/region?application=AxeChrome", + "impact": "moderate", + "needsReview": false, + "isManual": false, + "selector": ["h3:nth-child(11)"], + "summary": "Fix any of the following:\n Some page content is not contained by landmarks", + "source": "

        Outputs

        ", + "tags": ["cat.keyboard", "best-practice"], + "igt": "", + "testName": "QwikUI | Headless | Carousel", + "shareURL": "", + "createdAt": "2023-03-26T06:10:12.758Z", + "testUrl": "http://localhost:5173/docs/headless/carousel/", + "testPageTitle": "", + "foundBy": "thomas.leperou@hayvnglobal.com", + "axeVersion": "4.6.3" + } + ] +} diff --git a/packages/headless/src/components/carousel/carousel.tsx b/packages/headless/src/components/carousel/carousel.tsx new file mode 100644 index 000000000..35eb55db3 --- /dev/null +++ b/packages/headless/src/components/carousel/carousel.tsx @@ -0,0 +1,222 @@ +import { + component$, + createContextId, + QwikIntrinsicElements, + type Signal, + Slot, + useContext, + useContextProvider, + useSignal, + useStylesScoped$, + QRL, + useId, + $, +} from '@builder.io/qwik'; +import { useCarousel } from './use-carousel'; +import { useOrdinal } from './use-ordinal'; + +import stylesButtons from './styles-buttons.css?inline'; +import stylesControl from './styles-control.css?inline'; +import stylesItem from './styles-item.css?inline'; +import stylesItems from './styles-items.css?inline'; + +export type CarouselContext = { + id: string; + loop: boolean; + startAt: number; + active: Signal; + count: Signal; + next: QRL<() => void>; + previous: QRL<() => void>; + scrollTo: QRL<(index: number) => void>; + isFirstActive: Signal; + isLastActive: Signal; +}; + +export const carouselContext = + createContextId('carousel-root'); + +type RootProps = QwikIntrinsicElements['div'] & { + startAt?: number; + loop?: boolean; +}; + +export const Root = component$( + ({ startAt = 0, loop = true, id, ...props }: RootProps) => { + const itemsRef = useSignal(); + const contextService: CarouselContext = useCarousel({ + id, + itemsRef, + startAt, + loop, + }); + useContextProvider(carouselContext, contextService); + + return ( + <> +
          +
        • count: {contextService.count.value}
        • +
        • active: {contextService.active.value + 1}
        • +
        • + first: {contextService.isFirstActive.value ? 'true' : 'false'} +
        • +
        • last: {contextService.isLastActive.value ? 'true' : 'false'}
        • +
        • loop: {contextService.loop ? 'true' : 'false'}
        • +
        +
        + +
        + + ); + } +); + +type ButtonProps = QwikIntrinsicElements['button']; + +export const ButtonNext = component$(({ onClick$, ...props }: ButtonProps) => { + useStylesScoped$(stylesButtons); + const { isLastActive, loop, next } = useContext(carouselContext); + return ( + + ); +}); + +export const ButtonPrevious = component$( + ({ onClick$, ...props }: ButtonProps) => { + useStylesScoped$(stylesButtons); + const { isFirstActive, loop, previous } = useContext(carouselContext); + return ( + + ); + } +); + +export const Items = component$(() => { + useStylesScoped$(stylesItems); + return ( + + ); +}); + +type ItemProps = QwikIntrinsicElements['li'] & { + label: string; + index: number; +}; + +export const Item = component$( + ({ index, label, ...props }: Omit) => { + useStylesScoped$(stylesItem); + const { id, active } = useContext(carouselContext); + return ( +
      • + + +
      • + ); + } +); + +type ControlContext = { + id: string; +}; + +type ControlsProps = QwikIntrinsicElements['div']; + +export const controlContext = createContextId( + 'carousel-control-root' +); + +export const Controls = component$((props: ControlsProps) => { + const controlService = { id: props.id || useId() }; + useContextProvider(controlContext, controlService); + return ( + + ); +}); + +type ControlProps = QwikIntrinsicElements['div'] & { + index: number; +}; + +export const Control = component$( + ({ index, onClick$, ...props }: ControlProps) => { + useStylesScoped$(stylesControl); + const ordinal = useOrdinal(); + const { active, scrollTo } = useContext(carouselContext); + const { id } = useContext(controlContext); + + return ( +
        scrollTo(index)), onClick$]} + > + scrollTo(index)} + /> + +
        + ); + } +); + +export const IconPrevious = () => ( + + + +); + +export const IconNext = () => ( + + + +); diff --git a/packages/headless/src/components/carousel/styles-buttons.css b/packages/headless/src/components/carousel/styles-buttons.css new file mode 100644 index 000000000..16e3325f1 --- /dev/null +++ b/packages/headless/src/components/carousel/styles-buttons.css @@ -0,0 +1,3 @@ +:where(button:focus) { + z-index: 1; +} diff --git a/packages/headless/src/components/carousel/styles-control.css b/packages/headless/src/components/carousel/styles-control.css new file mode 100644 index 000000000..21c3da288 --- /dev/null +++ b/packages/headless/src/components/carousel/styles-control.css @@ -0,0 +1,8 @@ +:where(input[type='radio']) { + appearance: none; +} + +:where(div:focus-within) { + outline: -webkit-focus-ring-color auto 1px; + z-index: 1; +} diff --git a/packages/headless/src/components/carousel/styles-item.css b/packages/headless/src/components/carousel/styles-item.css new file mode 100644 index 000000000..b8c599949 --- /dev/null +++ b/packages/headless/src/components/carousel/styles-item.css @@ -0,0 +1,20 @@ +:where(li) { + /** + * CAUTION need at least 1px height to scroll to + * the active item on initial render (startAt) + */ + min-height: 1px; + /* */ + scroll-snap-align: start; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 100vw; + max-width: 100%; + height: 100%; +} + +:where(input[type='radio']) { + appearance: none; +} diff --git a/packages/headless/src/components/carousel/styles-items.css b/packages/headless/src/components/carousel/styles-items.css new file mode 100644 index 000000000..627a3012d --- /dev/null +++ b/packages/headless/src/components/carousel/styles-items.css @@ -0,0 +1,49 @@ +:where(.carousel) { + --scroll-width: 0.95em; + + display: grid; + grid-template-areas: + 'prev item next' + 'tbnl tbnl tbnl' + 'ctrl ctrl ctrl'; + align-items: center; + position: relative; +} + +:where(.carousel:focus-within) { + outline: -webkit-focus-ring-color auto 1px; + z-index: 1; +} + +:where(.carousel:hover) { + margin-bottom: calc(-1 * var(--scroll-width)); +} + +:where(.carousel:not(:hover)) .carousel__items { + /* 1px border */ + /* margin-bottom: var(--scroll-width); */ + /* prevent from CLS */ + -ms-overflow-style: none; + /* Internet Explorer 10+ */ + scrollbar-width: none; + /* Firefox */ +} + +:where(.carousel:not(:hover)) .carousel__items::-webkit-scrollbar { + display: none; + /* Safari and Chrome */ +} + +:where(.carousel__items) { + height: 100%; + position: relative; + display: flex; + align-items: center; + gap: 3rem; + overflow-x: auto; + scroll-snap-type: x mandatory; + overscroll-behavior-x: contain; + + -ms-overflow-style: var(--scroll-width); + scrollbar-width: var(--scroll-width); +} diff --git a/packages/headless/src/components/carousel/use-carousel.ts b/packages/headless/src/components/carousel/use-carousel.ts new file mode 100644 index 000000000..2121aa4f9 --- /dev/null +++ b/packages/headless/src/components/carousel/use-carousel.ts @@ -0,0 +1,130 @@ +import { + type Signal, + useSignal, + useVisibleTask$, + $, + useId, +} from '@builder.io/qwik'; +import type { CarouselContext } from './carousel'; + +type UseCarouselParams = { + id?: string; + itemsRef: Signal; + thumbnailsRef?: Signal; + startAt?: number; + loop?: boolean; +}; + +export const useCarousel = ({ + id = useId(), + itemsRef, + startAt = 0, + loop = true, +}: UseCarouselParams): CarouselContext => { + const isFirstActive = useSignal(false); + const isLastActive = useSignal(false); + const count = useSignal(0); + const active = useSignal(startAt); + + const trackActive = $((index: number) => { + active.value = index; + isFirstActive.value = index === 0; + isLastActive.value = index + 1 === count.value; + }); + + /** + * scroll to the active item + * track the active item + */ + const scrollTo = $((index: number) => { + if (!count.value) { + console.warn( + `Can't jump to ${index} because the carousel elements is empty.` + ); + return; + } + + if (index + 1 > count.value) { + console.warn( + `Can't jump to ${index} because the element index ${index} doesn't exist.` + ); + return; + } + + trackActive(index); + + const ref = itemsRef.value?.querySelector('.carousel__items'); + const el = ref?.children[index]; + el && + el.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'start', + }); + }); + + /** + * scroll to the -1 of the active item + */ + const previous = $(() => { + if (!loop && isFirstActive.value) { + return; + } + + scrollTo(active.value === 0 ? count.value - 1 : active.value - 1); + }); + + /** + * scroll to the +1 of the active item + */ + const next = $(() => { + const max = count.value - 1; + if (!loop && active.value === max) { + return; + } + + scrollTo(active.value === max ? 0 : active.value + 1); + }); + + /** + * initialise the scroll position + * track the active item when scrolling + */ + useVisibleTask$(() => { + // set total of item in the carousel + const ref = itemsRef.value?.querySelector('.carousel__items'); + count.value = ref?.childElementCount || 0; + + // scroll to the active item on initial render + scrollTo(active.value); + + // track active item while scrolling + const observer = new IntersectionObserver((items) => { + items.forEach((item) => { + // in case several items shall be handled within + // the view, use the ratio / number of visible item + // because several item shall be intersecting + if (item.isIntersecting) { + const index = Array.from(ref?.children || []).indexOf(item.target); + trackActive(index); + } + }); + }); + + const items = ref?.children || []; + Array.from(items).forEach((item) => observer.observe(item)); + }); + + return { + id, + loop, + startAt, + active, + count, + next, + previous, + scrollTo, + isFirstActive, + isLastActive, + }; +}; diff --git a/packages/headless/src/components/carousel/use-ordinal.ts b/packages/headless/src/components/carousel/use-ordinal.ts new file mode 100644 index 000000000..5200e662b --- /dev/null +++ b/packages/headless/src/components/carousel/use-ordinal.ts @@ -0,0 +1,14 @@ +import { noSerialize } from '@builder.io/qwik'; + +export const useOrdinal = () => { + return noSerialize((n: number) => { + const pr = new Intl.PluralRules('en-GB', { type: 'ordinal' }); + const suffixes = new Map([ + ['one', 'st'], + ['two', 'nd'], + ['few', 'rd'], + ['other', 'th'], + ]); + return `${n}${suffixes.get(pr.select(n))}`; + }); +}; diff --git a/packages/headless/src/index.ts b/packages/headless/src/index.ts index aa5f1ed5a..ef80837fd 100644 --- a/packages/headless/src/index.ts +++ b/packages/headless/src/index.ts @@ -2,6 +2,7 @@ export * from './components/accordion/accordion'; export * from './components/badge/badge'; export * from './components/button-group/button-group'; export * from './components/card'; +export * as Carousel from './components/carousel/carousel'; export * from './components/pagination/pagination'; export * from './components/collapse/collapse'; export * from './components/drawer';