diff --git a/.specs/logo-wall.md b/.specs/logo-wall.md new file mode 100644 index 000000000..a5f237a4a --- /dev/null +++ b/.specs/logo-wall.md @@ -0,0 +1,190 @@ +--- +name: logo-wall +category: marketing +structure: monolithic +status: implemented +spec_version: 2 +checksum: 43a8f298a7148af9f0078b47ccf3ad078b2c3e233062a9db743ff86c48285e3a +created: 2026-09-22 +last_updated: 2026-10-01 +--- + +# Logo Wall — Component Spec + +## Purpose + +The customer-proof band of a marketing page: a framed grid of square cells, one company mark to a cell, each one optionally linked to that customer's story, under one accessible group name. It is deliberately a static grid rather than an auto-scrolling strip — moving content needs a pause control to meet WCAG 2.2.2, and a wall a reader can scan beats one they have to wait for. + +The cells are `card-grid` frame cells, so the wall draws the same hairlines and registration marks as every other framed band on the page. A linked cell is the whole square: on hover and focus a wash rises behind it, the mark lifts, and a `Read story` label slides in under it, so the wall doubles as the way into the case studies. Fill the `aside` slot and the band splits from `lg` up — the wall on the start edge, one of those customers speaking on the end edge on a padded canvas panel framed like the cells, a registration mark in each of its corners and one shared hairline at its seam with the wall — and the wall narrows from six columns to three to sit in half the width at the same cell size. Every cell is a perfect square in every layout, and the wall alone sets the band's height: the `aside` panel is size-contained from `lg` up, so its content never stretches a row, and the statement sits centred in it. Between `lg` and `xl` the wall is two columns wide, so its three rows are tall enough for a client sentence; from `xl` it is three columns, two rows. Set `kind` to `rectangle` and every cell becomes a 3:2 rectangle instead: beside `aside` the wall holds three columns from `lg`, so nine rectangles take exactly the height six squares do, and below `sm` it keeps three columns so nine marks never leave a ragged row. + +## When to use + +- To show who already uses the product, as the proof band under a hero or section, with each mark leading to that customer's success case. +- Wherever a set of partner, customer or certification marks is the message. +- When the marks are recognisable enough that names are unnecessary. +- To set one customer's statement beside the crowd that backs it, by filling `aside` with a `quote`. + +## When NOT to use + +- For a measured claim rather than a set of names → use `big-numbers`. +- For one customer's statement → use `quote`. +- For a single brand mark, such as the product's own → use `brand`. +- For a grid of described features rather than marks → use `topic` in a `card-grid`. +- For a moving strip of many small marks, such as a tool stack → use `ticker`. + +## Related + +- `card-grid` — the frame register the wall is built on; its cells draw the rules and marks. +- `quote` — one named customer's statement; the wall shows many without words. +- `big-numbers` — the numeric form of the same proof band. +- `brand` — the product's own mark, not a customer's. + +## Best practices + +- Place the wall inside a `frame-box` (in a `section-module`, as every framed band is). The grid is `card-grid`'s `flush` frame: the surrounding frame draws the outer rules and the cells lay their right and bottom rules onto it. +- Supply marks that read on the page's own surface. The component applies no colour filter, so a mark that only works on one theme will not work on both; brand-colour marks read on either. When the marks come from a registry that already places them per theme, render them through the `mark` slot and keep `items` as the list they are drawn from. +- Set `shape: 'compact'` on a mark that is close to square (a roundel, a monogram) so it sits taller than the wordmarks beside it and all of them read at one visual weight. +- Give every mark a real `alt` — the company name. A linked cell is announced as `linkLabel`, then that name ("Read story, Magalu"). +- Set `ariaLabel` so the group announces its purpose ("Clients running on Azion") instead of reading as an unnamed region. +- Link a mark with `href` only when there is somewhere worth going, such as a case study; a wall of links to nowhere costs a reader a tab stop per cell. In an app with a client-side router, handle `item-click`: call `event.preventDefault()` and push `item.href`. +- Put one statement in `aside`, not a second grid. The band's argument is *many customers, one of them talking*; two things of equal weight leave a reader with neither. +- Give the wall exactly six marks when `aside` is filled — two full rows of three squares beside the statement, and three rows of two between `lg` and `xl` — and a multiple of six when it is not, so the last row is never ragged. +- Reach for `kind: 'rectangle'` when the band names nine customers beside `aside`: three rows of three 3:2 cells at the height of the six-square wall. Nine is the count the rectangle wall is drawn for; without `aside` it runs six to a row like the square wall. + +## Usage + +```vue + + + +``` + +## Props + +| Prop | Type | Default | Required | JSDoc | +|---|---|---|---|---| +| `kind` | `LogoWallKind` | `'square'` | false | The cell's proportion; `rectangle` sets every cell at 3:2, keeps three columns below `sm`, and holds three columns beside `aside` from `lg`. | +| `items` | `LogoItem[]` | `[]` | false | The marks rendered in the grid, in order; each item is `{ src, alt, href?, shape? }` where `src` is the mark's URL, `alt` names the company, `href` links the cell to that customer's story, and `shape` sets a near-square mark taller than a wordmark. | +| `ariaLabel` | `string` | `''` | false | Accessible name for the group of marks, announced instead of an unnamed region. | +| `linkLabel` | `string` | `'Read story'` | false | Words revealed under a linked mark on hover and focus, and the lead of that link's accessible name. | + +`LogoItem` is `{ src: string; alt: string; href?: string; shape?: LogoShape }`, `LogoShape` is `'wide' | 'compact'` (`wide` when omitted), and `LogoWallKind` is `'square' | 'rectangle'`. All three types are exported. + +## Events + +| Event | Payload | Notes | +|---|---|---| +| `item-click` | `(event: MouseEvent, item: LogoItem)` | Fired when a linked cell is activated; `item` is the matched `items` entry. The anchor still navigates unless the handler calls `event.preventDefault()`, which is how a client-side router takes over. | + +## Slots + +| Slot | Scope | Notes | +|---|---|---| +| `aside` | — | Content set beside the wall from `lg` up, centred in a panel that never sets the band's height (keep it short enough to fit two rows of squares from `xl`), such as one customer's `quote`, on a `--bg-canvas` panel padded by `--spacing-xl` and framed with a registration mark in each corner; its child fills the panel's width. When it is empty the wall spans the full width. | +| `mark` | `{ item: LogoItem; index: number }` | One cell's mark, replacing the image built from the item — for a mark that owns its own theming (a per-theme asset swap, a silhouette filter). The slotted content carries its own alternative text and sits in a 28px-tall box; on a linked cell it lifts with the built image's motion. | + +## States + +- Visual states: an unlinked cell is static; a linked cell on `hover` / `focus-visible` raises a `--bg-hover` wash behind the whole square, lifts the mark by `--spacing-sm`, and fades the `linkLabel` in beneath it with a trailing arrow +- `data-aside` is present when the `aside` slot is filled; from `lg` up it splits the band into two columns and narrows the wall from six columns to two, and to three from `xl`, so a cell is the same size in both layouts +- `data-kind` on the root is `square` or `rectangle`; `rectangle` sets every cell at 3:2 instead of 1:1 +- Columns: two below `sm`, three from `sm`, six from `lg` (with `aside`: two from `lg`, three from `xl`); a `rectangle` wall is three below `sm` and three from `lg` beside `aside` +- `data-shape` on the built image is `wide` or `compact`; `compact` sets the mark 28px tall instead of 20px +- A cell's mark is the `mark` slot's content when it is filled, and the image built from the item otherwise +- Empty: when `items` is empty the wall renders no grid, so a page with no customers to name shows nothing rather than an empty frame — an `aside` given without items still renders, since a statement stands on its own + +## Motion & Animations + +| Trigger | Animation / Transition | Token (see `.claude/docs/DESIGN.md` § Animations) | Reduced-motion fallback | +|---|---|---|---| +| hover / focus on a linked cell — the wash | `before:transition-opacity before:duration-moderate-01 before:ease-productive-entrance` | `duration-moderate-01` + `ease-productive-entrance` | `motion-reduce:before:transition-none` | +| hover / focus on a linked cell — the mark lift | `transition-[translate] duration-moderate-01 ease-productive-entrance` | `duration-moderate-01` + `ease-productive-entrance` | `motion-reduce:transition-none` | +| hover / focus on a linked cell — the label reveal | `transition-[opacity,translate] duration-moderate-01 ease-productive-entrance` | `duration-moderate-01` + `ease-productive-entrance` | `motion-reduce:transition-none` | + +## Tokens + +| Region | Token (DESIGN.md) | +|---|---| +| cell fill | `var(--bg-surface)` (via `card-grid` cell `surface`) | +| cell rules + marks | `var(--border-default)` (via `card-grid` frame) | +| hover wash | `var(--bg-hover)` | +| mark lift | `var(--spacing-sm)` | +| cell inline padding | `var(--spacing-md)` | +| label | `text-overline-md`, `var(--text-default)` | +| label ↔ mark gap | `var(--spacing-xs)` | +| label ↔ arrow gap | `var(--spacing-xxs)` | +| aside panel | `var(--bg-canvas)`, padded by `var(--spacing-xl)` | +| ring | `var(--ring-color)` | + +## Theme gaps + +| Figma variable | Temporary primitive | Follow-up | +|---|---|---| +| _none_ | — | — | + +## Accessibility (WCAG 2.1 AA) + +- Visible focus: a linked cell carries `focus-visible:ring-2 focus-visible:ring-(--ring-color) focus-visible:ring-inset`, inset so the ring stays inside the cell's rules; focus also lifts the mark and reveals the label, exactly as hover does. An unlinked cell is not focusable. +- Keyboard map: `Tab` reaches each linked cell in DOM order; `Enter` follows it. Unlinked cells are skipped. No arrow-key model — this is a list, not a composite widget. +- ARIA: the grid is `role="list"` of `role="listitem"` cells so the count is announced, inside a `section` named by `ariaLabel` when set. A linked cell's name is `", "`; the visible label is `aria-hidden` so it is not read twice. An unlinked cell's `alt` carries the company name. A mark supplied through the `mark` slot owns that name itself on an unlinked cell — the component cannot add it. +- Contrast ≥4.5:1 (text) / ≥3:1 (large + icons): the label is `--text-default` on `--bg-hover` over `--bg-surface`. The marks are consumer-supplied images; WCAG 1.4.11 exempts logotypes from the contrast minimum, and the component adds no colour filter, so a mark must carry its own legibility against `var(--bg-surface)`. Anything in `aside` is ordinary content and is bound by the minimum in full. +- `motion-reduce:transition-none` / `motion-reduce:before:transition-none` on every hover transition. The band never auto-scrolls, so WCAG 2.2.2 (pause, stop, hide) does not apply. +- Touch target ≥40×40 px — a linked cell's anchor is its whole square. + +## Stories (Storybook) + +- Default — six linked marks beside a `quote`, the Web Apps band (justified: this is the composition the component exists for, and the `aside` split, the three-column wall and the canvas panel only exist when the slot is filled, which no arg can produce) +- Wall — the same marks with no `aside`, six to a row (justified: the full-width six-column wall is the other layout the component has) +- Rectangle — nine linked marks beside a `quote` in 3:2 cells (justified: the rectangle cell and its three-column wall beside `aside` only exist with `kind: 'rectangle'` and nine items, which the square Default cannot show) +- Unlinked — marks with no `href` (justified: the static cell, with no wash, lift, label or tab stop, only exists without an `href`, and every other story links its marks) + +## Constraints — DO NOT + + + +- Do not add props beyond the Props table above. If you need a prop that is not listed, emit `BLOCKED: missing prop ` and stop — do not invent. +- Do not add events beyond the Events table above. Same rule for slots and sub-components. +- Do not invent imports. Every `@aziontech/webkit/*` path must exist in `packages/webkit/package.json#exports`. Every relative import must resolve to a real file. Every npm package must be installed. +- Do not use HEX/RGB/HSL colors, Tailwind palette names (e.g. `bg-blue-500`), raw typography classes (e.g. `text-sm`), `any`, `@ts-ignore`, or `class` inside `defineProps`. +- Do not install or import positioning/animation libraries (`@floating-ui/*`, `popper.js`, `tippy.js`, `gsap`, `framer-motion`, `motion`, `@vueuse/motion`, `@formkit/auto-animate`, drag-drop runtimes, scroll virtualization libs). Use CSS + Vue primitives (``, ``). See `.claude/rules/dependencies.md`. +- Do not improvise animations. Every `animate-*` / `transition-*` class must come from `packages/theme/src/tokens/semantic/animations.js`; every motion-bearing class pairs with `motion-reduce:*` on the same class string; no component-local `@keyframes`. +- Do not create class presets in JavaScript (`const kindClasses = {...}`, `const sharedClasses = [...]`, `const sizeClasses = {...}`, `const rootClasses = computed(...)`). Variants live on `data-*` attributes consumed by Tailwind `data-[attr=value]:`. All utilities live inline on the root element's `class` attribute. No ` + + + + + + + + + + + + + diff --git a/apps/storybook/static/clients/color/nzn.svg b/apps/storybook/static/clients/color/nzn.svg new file mode 100644 index 000000000..7805a75ce --- /dev/null +++ b/apps/storybook/static/clients/color/nzn.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/storybook/static/clients/color/renner.svg b/apps/storybook/static/clients/color/renner.svg new file mode 100644 index 000000000..2b96536c1 --- /dev/null +++ b/apps/storybook/static/clients/color/renner.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/storybook/static/clients/color/stone.svg b/apps/storybook/static/clients/color/stone.svg new file mode 100644 index 000000000..dd8a085bf --- /dev/null +++ b/apps/storybook/static/clients/color/stone.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/apps/storybook/static/clients/color/zoop.svg b/apps/storybook/static/clients/color/zoop.svg new file mode 100644 index 000000000..f3eb22209 --- /dev/null +++ b/apps/storybook/static/clients/color/zoop.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/webkit/.size-limit.json b/packages/webkit/.size-limit.json index f995bd048..a0d09ed32 100644 --- a/packages/webkit/.size-limit.json +++ b/packages/webkit/.size-limit.json @@ -134,6 +134,11 @@ "path": "src/components/layout/resizable-panel/resizable-panel.vue", "limit": "3 KB" }, + { + "name": "logo-wall", + "path": "src/components/marketing/logo-wall/logo-wall.vue", + "limit": "3.75 KB" + }, { "name": "media-split", "path": "src/components/marketing/media-split/media-split.vue", diff --git a/packages/webkit/catalog.json b/packages/webkit/catalog.json index 3412f7af5..10abb70e5 100644 --- a/packages/webkit/catalog.json +++ b/packages/webkit/catalog.json @@ -9871,6 +9871,95 @@ "parent": "sidebar", "treeShakeableImport": "@aziontech/webkit/sidebar-footer" }, + "logo-wall": { + "import": "@aziontech/webkit/logo-wall", + "target": "./src/components/marketing/logo-wall/logo-wall.vue", + "kind": "component", + "treeShakeableImport": "@aziontech/webkit/logo-wall", + "category": "marketing", + "structure": "monolithic", + "status": "implemented", + "props": [ + { + "name": "kind", + "type": "LogoWallKind", + "default": "'square'", + "required": "false", + "doc": "The cell's proportion; `rectangle` sets every cell at 3:2, keeps three columns below `sm`, and holds three columns beside `aside` from `lg`." + }, + { + "name": "items", + "type": "LogoItem[]", + "default": "[]", + "required": "false", + "doc": "The marks rendered in the grid, in order; each item is `{ src, alt, href?, shape? }` where `src` is the mark's URL, `alt` names the company, `href` links the cell to that customer's story, and `shape` sets a near-square mark taller than a wordmark." + }, + { + "name": "ariaLabel", + "type": "string", + "default": "''", + "required": "false", + "doc": "Accessible name for the group of marks, announced instead of an unnamed region." + }, + { + "name": "linkLabel", + "type": "string", + "default": "'Read story'", + "required": "false", + "doc": "Words revealed under a linked mark on hover and focus, and the lead of that link's accessible name." + } + ], + "events": [ + { + "name": "item-click", + "payload": "(event: MouseEvent, item: LogoItem)", + "notes": "Fired when a linked cell is activated; `item` is the matched `items` entry. The anchor still navigates unless the handler calls `event.preventDefault()`, which is how a client-side router takes over." + } + ], + "slots": [ + { + "name": "aside", + "scope": "—", + "notes": "Content set beside the wall from `lg` up, centred in a panel that never sets the band's height (keep it short enough to fit two rows of squares from `xl`), such as one customer's `quote`, on a `--bg-canvas` panel padded by `--spacing-xl` and framed with a registration mark in each corner; its child fills the panel's width. When it is empty the wall spans the full width." + }, + { + "name": "mark", + "scope": "{ item: LogoItem; index: number }", + "notes": "One cell's mark, replacing the image built from the item — for a mark that owns its own theming (a per-theme asset swap, a silhouette filter). The slotted content carries its own alternative text and sits in a 28px-tall box; on a linked cell it lifts with the built image's motion." + } + ], + "purpose": "The customer-proof band of a marketing page: a framed grid of square cells, one company mark to a cell, each one optionally linked to that customer's story, under one accessible group name. It is deliberately a static grid rather than an auto-scrolling strip — moving content needs a pause control to meet WCAG 2.2.2, and a wall a reader can scan beats one they have to wait for. The cells are `card-grid` frame cells, so the wall draws the same hairlines and registration marks as every other framed band on the page. A linked cell is the whole square: on hover and focus a wash rises behind it, the mark lifts, and a `Read story` label slides in under it, so the wall doubles as the way into the case studies. Fill the `aside` slot and the band splits from `lg` up — the wall on the start edge, one of those customers speaking on the end edge on a padded canvas panel framed like the cells, a registration mark in each of its corners and one shared hairline at its seam with the wall — and the wall narrows from six columns to three to sit in half the width at the same cell size. Every cell is a perfect square in every layout, and the wall alone sets the band's height: the `aside` panel is size-contained from `lg` up, so its content never stretches a row, and the statement sits centred in it. Between `lg` and `xl` the wall is two columns wide, so its three rows are tall enough for a client sentence; from `xl` it is three columns, two rows. Set `kind` to `rectangle` and every cell becomes a 3:2 rectangle instead: beside `aside` the wall holds three columns from `lg`, so nine rectangles take exactly the height six squares do, and below `sm` it keeps three columns so nine marks never leave a ragged row.", + "useWhen": [ + "To show who already uses the product, as the proof band under a hero or section, with each mark leading to that customer's success case.", + "Wherever a set of partner, customer or certification marks is the message.", + "When the marks are recognisable enough that names are unnecessary.", + "To set one customer's statement beside the crowd that backs it, by filling `aside` with a `quote`." + ], + "avoidWhen": [ + "For a measured claim rather than a set of names → use `big-numbers`.", + "For one customer's statement → use `quote`.", + "For a single brand mark, such as the product's own → use `brand`.", + "For a grid of described features rather than marks → use `topic` in a `card-grid`.", + "For a moving strip of many small marks, such as a tool stack → use `ticker`." + ], + "related": [ + "card-grid` — the frame register the wall is built on; its cells draw the rules and marks.", + "quote` — one named customer's statement; the wall shows many without words.", + "big-numbers` — the numeric form of the same proof band.", + "brand` — the product's own mark, not a customer's." + ], + "bestPractices": [ + "Place the wall inside a `frame-box` (in a `section-module`, as every framed band is). The grid is `card-grid`'s `flush` frame: the surrounding frame draws the outer rules and the cells lay their right and bottom rules onto it.", + "Supply marks that read on the page's own surface. The component applies no colour filter, so a mark that only works on one theme will not work on both; brand-colour marks read on either. When the marks come from a registry that already places them per theme, render them through the `mark` slot and keep `items` as the list they are drawn from.", + "Set `shape: 'compact'` on a mark that is close to square (a roundel, a monogram) so it sits taller than the wordmarks beside it and all of them read at one visual weight.", + "Give every mark a real `alt` — the company name. A linked cell is announced as `linkLabel`, then that name (\"Read story, Magalu\").", + "Set `ariaLabel` so the group announces its purpose (\"Clients running on Azion\") instead of reading as an unnamed region.", + "Link a mark with `href` only when there is somewhere worth going, such as a case study; a wall of links to nowhere costs a reader a tab stop per cell. In an app with a client-side router, handle `item-click`: call `event.preventDefault()` and push `item.href`.", + "Put one statement in `aside`, not a second grid. The band's argument is *many customers, one of them talking*; two things of equal weight leave a reader with neither.", + "Give the wall exactly six marks when `aside` is filled — two full rows of three squares beside the statement, and three rows of two between `lg` and `xl` — and a multiple of six when it is not, so the last row is never ragged.", + "Reach for `kind: 'rectangle'` when the band names nine customers beside `aside`: three rows of three 3:2 cells at the height of the six-square wall. Nine is the count the rectangle wall is drawn for; without `aside` it runs six to a row like the square wall." + ] + }, "media-split": { "import": "@aziontech/webkit/media-split", "target": "./src/components/marketing/media-split/media-split.vue", diff --git a/packages/webkit/package.json b/packages/webkit/package.json index 6bbc5369b..055116404 100644 --- a/packages/webkit/package.json +++ b/packages/webkit/package.json @@ -279,6 +279,7 @@ "./sidebar-group": "./src/components/layout/sidebar/sidebar-group.vue", "./sidebar-header": "./src/components/layout/sidebar/sidebar-header.vue", "./sidebar-footer": "./src/components/layout/sidebar/sidebar-footer.vue", + "./logo-wall": "./src/components/marketing/logo-wall/logo-wall.vue", "./media-split": "./src/components/marketing/media-split/media-split.vue", "./ticker": "./src/components/marketing/ticker/ticker.vue", "./bento-grid": "./src/components/marketing/bento-grid/index.ts", diff --git a/packages/webkit/src/components/marketing/logo-wall/logo-wall.test.ts b/packages/webkit/src/components/marketing/logo-wall/logo-wall.test.ts new file mode 100644 index 000000000..89a4ccfb8 --- /dev/null +++ b/packages/webkit/src/components/marketing/logo-wall/logo-wall.test.ts @@ -0,0 +1,243 @@ +import { fireEvent, render } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' + +import { expectNoA11yViolations } from '../../../test/axe' +import LogoWall from './logo-wall.vue' + +const TESTID = 'marketing-logo-wall' + +const items = [ + { src: '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/logos/northwind.svg', alt: 'Northwind' }, + { src: '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/logos/contoso.svg', alt: 'Contoso', href: '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/customers/contoso' }, + { src: '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/logos/fabrikam.svg', alt: 'Fabrikam', shape: 'compact' as const } +] + +describe('LogoWall', () => { + it('renders the wall under the default testid', () => { + const { getByTestId } = render(LogoWall, { props: { items } }) + + expect(getByTestId(TESTID)).toBeInTheDocument() + }) + + it('lets a consumer data-testid win over the fallback', () => { + const { getByTestId, queryByTestId } = render(LogoWall, { + props: { items }, + attrs: { 'data-testid': 'customer-proof' } + }) + + expect(getByTestId('customer-proof')).toBeInTheDocument() + expect(queryByTestId(TESTID)).toBeNull() + }) + + it('renders one list item per mark', () => { + const { getByRole, getAllByRole } = render(LogoWall, { props: { items } }) + + expect(getByRole('list')).toBeInTheDocument() + expect(getAllByRole('listitem')).toHaveLength(items.length) + }) + + it('sets a compact mark apart from the wide default through data-shape', () => { + const { getByAltText } = render(LogoWall, { props: { items } }) + + expect(getByAltText('Fabrikam')).toHaveAttribute('data-shape', 'compact') + expect(getByAltText('Northwind')).toHaveAttribute('data-shape', 'wide') + }) + + it('renders every mark as an image carrying its src and alt', () => { + const { getByAltText, container } = render(LogoWall, { props: { items } }) + + expect(container.querySelectorAll('img')).toHaveLength(items.length) + + for (const item of items) { + expect(getByAltText(item.alt)).toHaveAttribute('src', item.src) + } + }) + + it('wraps a mark that carries an href in an anchor to that destination', () => { + const { getByAltText } = render(LogoWall, { props: { items } }) + const anchor = getByAltText('Contoso').closest('a') + + expect(anchor).not.toBeNull() + expect(anchor).toHaveAttribute('href', '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/customers/contoso') + }) + + it('names a linked cell with the link label and the company', () => { + const { getByRole } = render(LogoWall, { props: { items } }) + + expect(getByRole('link', { name: 'Read story, Contoso' })).toHaveAttribute( + 'href', + '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/customers/contoso' + ) + }) + + it('uses a consumer linkLabel for the revealed words and the link name', () => { + const { getByRole, getByText } = render(LogoWall, { + props: { items, linkLabel: 'Ver caso' } + }) + + expect(getByRole('link', { name: 'Ver caso, Contoso' })).toBeInTheDocument() + expect(getByText('Ver caso').closest('[aria-hidden="true"]')).not.toBeNull() + }) + + it('emits item-click with the event first and the matched item second', async () => { + const { getByRole, emitted } = render(LogoWall, { props: { items } }) + const link = getByRole('link', { name: 'Read story, Contoso' }) + link.addEventListener('click', (event) => event.preventDefault()) + + await fireEvent.click(link) + + const calls = emitted('item-click') as [MouseEvent, (typeof items)[number]][] + expect(calls).toHaveLength(1) + expect(calls[0][0]).toBeInstanceOf(MouseEvent) + expect(calls[0][1]).toEqual(items[1]) + }) + + it('renders no anchor for a mark without an href', () => { + const { getByAltText, getAllByText, container } = render(LogoWall, { props: { items } }) + + expect(getByAltText('Northwind').closest('a')).toBeNull() + expect(container.querySelectorAll('a')).toHaveLength(1) + expect(getAllByText('Read story')).toHaveLength(1) + }) + + it('names the group with ariaLabel', () => { + const { getByTestId, getByRole } = render(LogoWall, { + props: { items, ariaLabel: 'Customers building on Azion' } + }) + + expect(getByTestId(TESTID)).toHaveAttribute('aria-label', 'Customers building on Azion') + expect(getByRole('region', { name: 'Customers building on Azion' })).toBeInTheDocument() + }) + + it('carries no aria-label when ariaLabel is unset', () => { + const { getByTestId } = render(LogoWall, { props: { items } }) + + expect(getByTestId(TESTID)).not.toHaveAttribute('aria-label') + }) + + it('renders no list when items is empty', () => { + const { getByTestId, container } = render(LogoWall, { props: { items: [] } }) + + expect(getByTestId(TESTID)).toBeInTheDocument() + expect(container.querySelector('[role="list"]')).toBeNull() + }) + + it('renders no list when items is omitted', () => { + const { container } = render(LogoWall) + + expect(container.querySelector('[role="list"]')).toBeNull() + }) + + it('forwards a consumer class onto the root', () => { + const { getByTestId } = render(LogoWall, { + props: { items }, + attrs: { class: 'max-w-(--container-4xl)' } + }) + + const root = getByTestId(TESTID) + expect(root.tagName).toBe('SECTION') + expect(root.className).toContain('max-w-(--container-4xl)') + }) + + it('renders the mark slot in place of the image built from the item', () => { + const { container, queryByAltText, getByAltText } = render(LogoWall, { + props: { items }, + slots: { mark: '' } + }) + + expect(container.querySelectorAll('img')).toHaveLength(items.length) + expect(queryByAltText('Northwind')).toBeNull() + expect(getByAltText('Northwind mark')).toHaveAttribute('src', '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/registry/mark.svg') + }) + + it('keeps a slotted mark inside the anchor when the item carries an href', () => { + const { getByAltText } = render(LogoWall, { + props: { items }, + slots: { mark: '' } + }) + + expect(getByAltText('Contoso mark').closest('a')).toHaveAttribute('href', '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/customers/contoso') + }) + + it('marks the wall square by default through data-kind', () => { + const { getByTestId } = render(LogoWall, { props: { items } }) + + expect(getByTestId(TESTID)).toHaveAttribute('data-kind', 'square') + }) + + it('marks a rectangle wall through data-kind', () => { + const { getByTestId } = render(LogoWall, { props: { items, kind: 'rectangle' } }) + + expect(getByTestId(TESTID)).toHaveAttribute('data-kind', 'rectangle') + }) + + it('carries no data-aside when the aside slot is empty', () => { + const { getByTestId } = render(LogoWall, { props: { items } }) + + expect(getByTestId(TESTID)).not.toHaveAttribute('data-aside') + }) + + it('marks the band with data-aside and renders the slot beside the wall', () => { + const { getByTestId, getByText } = render(LogoWall, { + props: { items }, + slots: { aside: '
Azion transformed our operations.
' } + }) + + const root = getByTestId(TESTID) + expect(root).toHaveAttribute('data-aside', 'true') + expect(getByText('Azion transformed our operations.')).toBeInTheDocument() + expect(root.querySelector('[role="list"]')).not.toBeNull() + }) + + it('frames the aside with a mark in every corner and a seam beside the wall', () => { + const { getByText } = render(LogoWall, { + props: { items }, + slots: { aside: '
Azion transformed our operations.
' } + }) + + const panel = getByText('Azion transformed our operations.').closest('[data-marks]') + expect(panel).toHaveAttribute('data-marks', 'top-left top-right bottom-left bottom-right') + expect(panel).toHaveAttribute('data-seam', 'true') + }) + + it('draws no seam on an aside that stands alone', () => { + const { getByText } = render(LogoWall, { + props: { items: [] }, + slots: { aside: '
One customer still speaks.
' } + }) + + const panel = getByText('One customer still speaks.').closest('[data-marks]') + expect(panel).toHaveAttribute('data-marks', 'top-left top-right bottom-left bottom-right') + expect(panel).not.toHaveAttribute('data-seam') + }) + + it('renders the aside even when there are no marks to show', () => { + const { getByTestId, getByText } = render(LogoWall, { + props: { items: [] }, + slots: { aside: '
One customer still speaks.
' } + }) + + expect(getByTestId(TESTID).querySelector('[role="list"]')).toBeNull() + expect(getByText('One customer still speaks.')).toBeInTheDocument() + }) + + it('has no a11y violations with a full wall', async () => { + const { container } = render(LogoWall, { + props: { items, ariaLabel: 'Customers building on Azion' } + }) + + await expectNoA11yViolations(container) + }) + + it('has no a11y violations with the aside filled', async () => { + const { container } = render(LogoWall, { + props: { items, ariaLabel: 'Customers building on Azion' }, + slots: { + aside: + '
Azion transformed our operations.
Mateus Leonardi, CTO at Contoso
' + } + }) + + await expectNoA11yViolations(container) + }) +}) diff --git a/packages/webkit/src/components/marketing/logo-wall/logo-wall.vue b/packages/webkit/src/components/marketing/logo-wall/logo-wall.vue new file mode 100644 index 000000000..369ad65b5 --- /dev/null +++ b/packages/webkit/src/components/marketing/logo-wall/logo-wall.vue @@ -0,0 +1,170 @@ + + +