@carissa-io/webkit-testimonials 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,144 @@
1
- # Temporary Holding Version
1
+ # @carissa-io/webkit-testimonials
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One large customer quote at a time, with who said it, and previous / next icon buttons. A React component, SCSS-themed, safe
4
+ on the server; its only runtime dependency is `@carissa-io/components-icon` (the chevrons and the pause / play icons). It is not a grid of avatar cards and not a carousel pattern: it rotates only if the
5
+ site opts in with `autoplay_interval` (and then offers a pause button), and there is no motion on mount, no gradient and no default shadow. The only motion is a soft fade of the new quote
6
+ after a change.
7
+
8
+ **The component has no strings of its own.** The site passes every quote, name and label. The look comes from the theme's
9
+ second stylesheet, `@carissa-io/theme-default/webkit.css`, which apps do not load.
10
+
11
+ ```bash
12
+ npm i @carissa-io/webkit-testimonials
13
+ ```
14
+
15
+ ## Use
16
+
17
+ ```jsx
18
+ import { Testimonials } from '@carissa-io/webkit-testimonials'
19
+
20
+ <Testimonials
21
+ title={t('quotes_title')}
22
+ label={t('quotes_region')}
23
+ previous_label={t('previous')}
24
+ next_label={t('next')}
25
+ counter_label={(current, total) => t('quote_counter', { current, total })}
26
+ items={[
27
+ { id: 'ada', quote: t('ada_quote'), name: 'Ada Example', role: t('ada_role'), source: 'Example Co' },
28
+ { id: 'ben', quote: t('ben_quote'), name: 'Ben Sample', role: t('ben_role'), avatar: <img src="/ben.png" alt="" /> },
29
+ ]}
30
+ />
31
+ ```
32
+
33
+ | Prop | |
34
+ |---|---|
35
+ | `items` | `[{ id, quote, name, role?, source?, avatar? }]`. `quote` is a string or any node, `name` is required, `role` and `source` are optional text, `avatar` is an optional node the site supplies, `id` the React key (the index when missing). An item without a quote or a name is skipped; anything that is not an array renders no figure |
36
+ | `index` | the current item, **controlled**: the parent decides and the buttons only report through `on_change` |
37
+ | `default_index` | the first item when uncontrolled, `0` by default |
38
+ | `on_change` | `(index) => void`, called with the new index on every button click, in both modes |
39
+ | `autoplay_interval` | optional, milliseconds. Turns the automatic rotation on; off when missing, not a finite number, below `2000`, or with fewer than two items. See [Autoplay](#autoplay) |
40
+ | `pause_label`, `play_label` | the accessible names (`aria-label`) of the pause / play toggle: `pause_label` while the rotation plays, `play_label` while it is paused. Only used when autoplay is on. **Required for accessibility** like `previous_label`: a missing or blank label leaves the toggle without a name; the piece invents no string |
41
+ | `title` | optional heading text; no heading when empty |
42
+ | `title_level` | 1 to 6, default `2`; anything else falls back to `2` |
43
+ | `previous_label`, `next_label` | the accessible names (`aria-label`) of the two icon-only buttons. **Required for accessibility**; never built in. A missing or blank label leaves that button without an `aria-label` (and so without a name): the piece does not invent a string, so always pass both |
44
+ | `counter_label` | optional: a string, or a function `(current, total) => string`. Without it the counter shows `1 / 3` |
45
+ | `label` | optional `aria-label` of the region |
46
+ | `className`, `id`, ... | `className` is appended; unknown props go to the root (a `section`) |
47
+
48
+ An invalid `index` or `default_index` (not an integer inside the list) falls back to `0`; if the list shrinks, the position
49
+ is clamped the same way. The buttons wrap around: next on the last quote shows the first. With a single item there are no
50
+ controls, no counter and no live region.
51
+
52
+ ## Autoplay
53
+
54
+ Opt in with `autoplay_interval={8000}` (milliseconds, at least `2000`). Every interval the current item advances to the next
55
+ and wraps from the last to the first: uncontrolled, the piece sets its own index; controlled, it calls `on_change(next)` and
56
+ the parent applies it (a parent that ignores it keeps being asked every interval). The new quote gets the same fade as after
57
+ a button press, but the live region stays silent: only user-driven changes are announced.
58
+
59
+ The rotation pauses, and starts a fresh full interval when it resumes, while:
60
+
61
+ - the pointer is over the piece,
62
+ - focus is inside the piece,
63
+ - the page tab is hidden (`document.visibilityState`),
64
+ - the user pressed the pause button (a **manual pause** lasts until the button is pressed again; leaving with the pointer or focus does not resume it).
65
+
66
+ Pressing previous or next restarts the interval. A real `button type="button"` (`c-testimonials__button c-testimonials__button--toggle`) renders next to
67
+ previous / next only while autoplay is on, icon-only, drawing the Lucide `PauseIcon` while playing and `PlayIcon` while paused
68
+ (decorative `aria-hidden`); it has no `aria-pressed`, its name switches between `pause_label` and `play_label`. With
69
+ `prefers-reduced-motion: reduce` the rotation does not start and the toggle is not rendered; the preference is followed
70
+ live. All timers and listeners are removed on unmount and when the prop turns off. On the server nothing runs: the browser
71
+ state (media query, visibility) is read only in the client, and a missing `matchMedia` means no preference.
72
+
73
+ ```jsx
74
+ <Testimonials items={items} previous_label={t('previous')} next_label={t('next')}
75
+ autoplay_interval={8000} pause_label={t('pause')} play_label={t('play')} />
76
+ ```
77
+
78
+ ## Accessibility
79
+
80
+ - **A `figure`, not a carousel.** The current quote is a `figure` with a `blockquote` and a `figcaption` (name, role and
81
+ source). The piece does not use `aria-roledescription="carousel"` or slide semantics; it shows one quote and swaps it.
82
+ - **Buttons are the only tab stops.** Previous, next and, with autoplay, the pause / play toggle are real `button type="button"`, reachable in that order, activated
83
+ with Enter or Space, and styled by the theme's focus ring (the piece styles no `outline`). They are icon-only (a
84
+ decorative `aria-hidden` chevron, no visible text); their accessible name comes from `previous_label`, `next_label`, `pause_label` and `play_label`.
85
+ - **The new position is announced, not the whole quote.** A visually hidden `aria-live="polite"` element stays empty on
86
+ mount and receives the counter text (`counter_label`, or `2 / 3`) only after the user presses a button. A change driven
87
+ by a controlled `index` from outside is not announced.
88
+ - **Moving content can be stopped (WCAG 2.2.2 Pause, Stop, Hide).** Autoplay is off unless the site opts in; when on, it
89
+ pauses on hover, on focus inside and in a hidden tab, always offers the pause / play button, honours
90
+ `prefers-reduced-motion: reduce` (no rotation, no toggle), and never rotates a single item. Automatic changes are not
91
+ announced, so a screen reader is not interrupted; the visible counter still updates.
92
+ - **One soft fade, after a change.** No transition and no motion on mount or on the server.
93
+ When the user presses previous / next (or autoplay advances) and the item changes, the new figure is re-keyed and gets
94
+ `c-testimonials__figure--enter`: an opacity-only CSS keyframe (0 to 1; no movement, no layout shift). A change driven by a
95
+ controlled `index` from outside, a single item and the first render never fade. Under `prefers-reduced-motion: reduce`
96
+ the piece's SCSS sets `animation: none`, so the change is instant. The default duration is `0s` (inert); the theme sets
97
+ one. The visible counter (`current / total`) is plain text.
98
+ - The title is a real heading (`title_level`); `label` names the region for screen reader landmark lists.
99
+
100
+ ## Layout
101
+
102
+ A single column (`$c-testimonials-align: center` centres the quote, attribution and controls; the 65ch measure box stays
103
+ centred in its container): the quote, capped by `$c-testimonials-quote-max-width` (65ch, the text measure), then the attribution (avatar
104
+ node, name, role, source), then the controls. Long quotes and long names wrap (`overflow-wrap`), nothing is truncated and
105
+ there are no fixed widths. The avatar is a slot: the site supplies the node (usually an `img`) and the piece only limits
106
+ its box to `$c-testimonials-avatar-size`.
107
+
108
+ ## Style
109
+
110
+ ```scss
111
+ @use '@carissa-io/webkit-testimonials/scss' with (
112
+ $c-testimonials-quote-font-size: 2.5rem,
113
+ $c-testimonials-button-border-width: 1px
114
+ );
115
+ ```
116
+
117
+ Classes: `c-testimonials`, `c-testimonials__header`, `c-testimonials__title`, `c-testimonials__figure`,
118
+ `c-testimonials__quote`, `c-testimonials__cite`, `c-testimonials__avatar`, `c-testimonials__person`, `c-testimonials__name`,
119
+ `c-testimonials__role`, `c-testimonials__source`, `c-testimonials__controls`, `c-testimonials__figure--enter` (the fade, added only after a change),
120
+ `c-testimonials__button` (with `c-testimonials__button--previous`, `--next` and `--toggle`, the autoplay pause / play button; each holds one `c-icon` svg), `c-testimonials__counter`, `c-testimonials__status` (the hidden live
121
+ region). Every variable is `!default` (see [scss/_variables.scss](scss/_variables.scss)); the defaults are neutral
122
+ (inherited colours, transparent background, no rules).
123
+
124
+ | Variable | Default |
125
+ |---|---|
126
+ | `$c-testimonials-font-family`, `-color`, `-background` | `inherit`, `inherit`, `transparent` |
127
+ | `$c-testimonials-align` | `start` (`start` or `center`: `text-align` and flex alignment of figure, attribution and controls) |
128
+ | `$c-testimonials-max-width` | `80rem` (header, figure and controls, centred) |
129
+ | `$c-testimonials-padding-block`, `-padding-inline` | `4rem`, `1rem` |
130
+ | `$c-testimonials-header-margin-bottom` | `2rem` |
131
+ | `$c-testimonials-title-font-size`, `-font-weight`, `-line-height`, `-color`, `-max-width` | `1.75rem`, `inherit`, `1.15`, `inherit`, `40ch` |
132
+ | `$c-testimonials-gap` | `1.5rem` (quote to attribution) |
133
+ | `$c-testimonials-quote-font-size`, `-font-weight`, `-line-height`, `-letter-spacing`, `-color`, `-max-width` | `2rem`, `inherit`, `1.3`, `normal`, `inherit`, `65ch` |
134
+ | `$c-testimonials-cite-gap`, `-avatar-size`, `-person-gap` | `1rem`, `3rem`, `0.125rem` |
135
+ | `$c-testimonials-name-font-size`, `-font-weight`, `-color` | `1rem`, `inherit`, `inherit` |
136
+ | `$c-testimonials-role-font-size`, `-color` | `0.875rem`, `inherit` |
137
+ | `$c-testimonials-source-font-size`, `-color` | `0.875rem`, `inherit` |
138
+ | `$c-testimonials-fade-duration`, `-fade-easing` | `0s` (no fade), `ease` |
139
+ | `$c-testimonials-controls-gap`, `-controls-margin-top` | `1rem`, `2rem` |
140
+ | `$c-testimonials-button-padding-block`, `-padding-inline` | `0.5rem`, `1rem` |
141
+ | `$c-testimonials-button-font-family`, `-font-size`, `-font-weight`, `-color`, `-background` | `inherit`, `0.875rem`, `inherit`, `inherit`, `transparent` |
142
+ | `$c-testimonials-button-border-width`, `-border-style`, `-border-color`, `-radius`, `-cursor` | `0`, `solid`, `currentcolor`, `0`, `pointer` |
143
+ | `$c-testimonials-counter-font-size`, `-counter-color` | `0.875rem`, `inherit` |
144
+ | `$c-testimonials-link-color`, `-link-text-decoration` | `inherit`, `underline` |
package/dist/index.js ADDED
@@ -0,0 +1,155 @@
1
+ // src/Testimonials.jsx
2
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ import { ChevronLeftIcon, ChevronRightIcon, PauseIcon, PlayIcon } from "@carissa-io/components-icon";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ var LEVELS = [1, 2, 3, 4, 5, 6];
6
+ var join = (...parts) => parts.filter(Boolean).join(" ");
7
+ var filled = (value) => value !== void 0 && value !== null && value !== false && value !== true && value !== "";
8
+ var text = (value) => typeof value === "string" && value.trim() !== "";
9
+ var valid_index = (value, total) => Number.isInteger(value) && value >= 0 && value < total;
10
+ var MIN_AUTOPLAY_INTERVAL = 2e3;
11
+ var REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
12
+ var media_query = () => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(REDUCED_MOTION) : null;
13
+ var subscribe_reduced = (notify) => {
14
+ const query = media_query();
15
+ if (!query) return () => {
16
+ };
17
+ if (typeof query.addEventListener === "function") {
18
+ query.addEventListener("change", notify);
19
+ return () => query.removeEventListener("change", notify);
20
+ }
21
+ query.addListener?.(notify);
22
+ return () => query.removeListener?.(notify);
23
+ };
24
+ var read_reduced = () => media_query()?.matches === true;
25
+ var subscribe_visibility = (notify) => {
26
+ document.addEventListener("visibilitychange", notify);
27
+ return () => document.removeEventListener("visibilitychange", notify);
28
+ };
29
+ var read_hidden = () => document.visibilityState === "hidden";
30
+ var never = () => false;
31
+ function Testimonials({
32
+ items,
33
+ index,
34
+ default_index = 0,
35
+ on_change,
36
+ title,
37
+ title_level = 2,
38
+ previous_label,
39
+ next_label,
40
+ pause_label,
41
+ play_label,
42
+ autoplay_interval,
43
+ counter_label,
44
+ label,
45
+ className = "",
46
+ onMouseEnter,
47
+ onMouseLeave,
48
+ onFocus,
49
+ onBlur,
50
+ ...rest
51
+ }) {
52
+ const [inner, set_inner] = useState(default_index);
53
+ const [announced, set_announced] = useState(null);
54
+ const [changed, set_changed] = useState(null);
55
+ const [cycle, set_cycle] = useState(0);
56
+ const [hovered, set_hovered] = useState(false);
57
+ const [focused, set_focused] = useState(false);
58
+ const [manual_pause, set_manual_pause] = useState(false);
59
+ const reduced = useSyncExternalStore(subscribe_reduced, read_reduced, never);
60
+ const hidden = useSyncExternalStore(subscribe_visibility, read_hidden, never);
61
+ const level = Number(title_level);
62
+ const Heading = `h${LEVELS.includes(level) ? level : 2}`;
63
+ const list = Array.isArray(items) ? items.filter((item2) => item2 && typeof item2 === "object" && !Array.isArray(item2) && filled(item2.quote) && filled(item2.name)) : [];
64
+ const total = list.length;
65
+ const wanted = index === void 0 ? inner : index;
66
+ const current = valid_index(wanted, total) ? wanted : 0;
67
+ const item = list[current];
68
+ const has_title = text(title);
69
+ const counter_text = (position) => {
70
+ if (typeof counter_label === "function") return counter_label(position, total);
71
+ if (text(counter_label)) return counter_label;
72
+ return `${position} / ${total}`;
73
+ };
74
+ const autoplay = typeof autoplay_interval === "number" && Number.isFinite(autoplay_interval) && autoplay_interval >= MIN_AUTOPLAY_INTERVAL && total > 1 && !reduced;
75
+ const running = autoplay && !manual_pause && !hovered && !focused && !hidden;
76
+ const move = (next, by_user) => {
77
+ if (index === void 0) set_inner(next);
78
+ set_changed(next);
79
+ set_announced(by_user ? next : null);
80
+ set_cycle((value) => value + 1);
81
+ if (typeof on_change === "function") on_change(next);
82
+ };
83
+ const go = (step) => move((current + step + total) % total, true);
84
+ const advance = useRef(null);
85
+ useEffect(() => {
86
+ advance.current = () => move((current + 1) % total, false);
87
+ });
88
+ useEffect(() => {
89
+ if (!running) return void 0;
90
+ const timer = setTimeout(() => advance.current(), autoplay_interval);
91
+ return () => clearTimeout(timer);
92
+ }, [running, autoplay_interval, cycle]);
93
+ const leaving = (event) => !event.currentTarget.contains(event.relatedTarget);
94
+ const after_interaction = announced !== null && announced === current;
95
+ const fading = changed !== null && changed === current;
96
+ const status = after_interaction ? counter_text(current + 1) : "";
97
+ const button_label = (value) => text(value) ? value : void 0;
98
+ return /* @__PURE__ */ jsxs(
99
+ "section",
100
+ {
101
+ ...rest,
102
+ onMouseEnter: (event) => {
103
+ set_hovered(true);
104
+ if (typeof onMouseEnter === "function") onMouseEnter(event);
105
+ },
106
+ onMouseLeave: (event) => {
107
+ set_hovered(false);
108
+ if (typeof onMouseLeave === "function") onMouseLeave(event);
109
+ },
110
+ onFocus: (event) => {
111
+ set_focused(true);
112
+ if (typeof onFocus === "function") onFocus(event);
113
+ },
114
+ onBlur: (event) => {
115
+ if (leaving(event)) set_focused(false);
116
+ if (typeof onBlur === "function") onBlur(event);
117
+ },
118
+ "aria-label": text(label) ? label : void 0,
119
+ className: join("c-testimonials", className),
120
+ children: [
121
+ has_title ? /* @__PURE__ */ jsx("div", { className: "c-testimonials__header", children: /* @__PURE__ */ jsx(Heading, { className: "c-testimonials__title", children: title }) }) : null,
122
+ item ? /* @__PURE__ */ jsxs("figure", { className: join("c-testimonials__figure", fading && "c-testimonials__figure--enter"), children: [
123
+ /* @__PURE__ */ jsx("blockquote", { className: "c-testimonials__quote", children: item.quote }),
124
+ /* @__PURE__ */ jsxs("figcaption", { className: "c-testimonials__cite", children: [
125
+ filled(item.avatar) ? /* @__PURE__ */ jsx("span", { className: "c-testimonials__avatar", children: item.avatar }) : null,
126
+ /* @__PURE__ */ jsxs("span", { className: "c-testimonials__person", children: [
127
+ /* @__PURE__ */ jsx("span", { className: "c-testimonials__name", children: item.name }),
128
+ filled(item.role) ? /* @__PURE__ */ jsx("span", { className: "c-testimonials__role", children: item.role }) : null,
129
+ filled(item.source) ? /* @__PURE__ */ jsx("span", { className: "c-testimonials__source", children: item.source }) : null
130
+ ] })
131
+ ] })
132
+ ] }, current) : null,
133
+ total > 1 ? /* @__PURE__ */ jsxs("div", { className: "c-testimonials__controls", children: [
134
+ /* @__PURE__ */ jsx("button", { type: "button", className: "c-testimonials__button c-testimonials__button--previous", "aria-label": button_label(previous_label), onClick: () => go(-1), children: /* @__PURE__ */ jsx(ChevronLeftIcon, {}) }),
135
+ /* @__PURE__ */ jsx("span", { className: "c-testimonials__counter", children: counter_text(current + 1) }),
136
+ /* @__PURE__ */ jsx("button", { type: "button", className: "c-testimonials__button c-testimonials__button--next", "aria-label": button_label(next_label), onClick: () => go(1), children: /* @__PURE__ */ jsx(ChevronRightIcon, {}) }),
137
+ autoplay ? /* @__PURE__ */ jsx(
138
+ "button",
139
+ {
140
+ type: "button",
141
+ className: "c-testimonials__button c-testimonials__button--toggle",
142
+ "aria-label": button_label(manual_pause ? play_label : pause_label),
143
+ onClick: () => set_manual_pause((value) => !value),
144
+ children: manual_pause ? /* @__PURE__ */ jsx(PlayIcon, {}) : /* @__PURE__ */ jsx(PauseIcon, {})
145
+ }
146
+ ) : null,
147
+ /* @__PURE__ */ jsx("span", { className: "c-testimonials__status", "aria-live": "polite", "aria-atomic": "true", children: status })
148
+ ] }) : null
149
+ ]
150
+ }
151
+ );
152
+ }
153
+ export {
154
+ Testimonials
155
+ };
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@carissa-io/webkit-testimonials",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Carissa web kit: testimonials, one quote at a time, as a React component with SCSS variables",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "files": [
8
+ "dist",
9
+ "scss",
10
+ "THIRD-PARTY-NOTICES"
11
+ ],
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./scss": "./scss/index.scss",
15
+ "./scss/*": "./scss/*"
16
+ },
17
+ "sideEffects": false,
18
+ "scripts": {
19
+ "build": "esbuild src/index.js --bundle --packages=external --format=esm --jsx=automatic --outfile=dist/index.js",
20
+ "test": "vitest run",
21
+ "lint": "eslint .",
22
+ "prepublishOnly": "node -e \"if (!process.env.GITHUB_ACTIONS) { console.error('Refusing to publish outside GitHub Actions CI: commit and push to main instead.'); process.exit(1); }\" && npm run build"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/carissa-io/webkit-testimonials.git"
27
+ },
28
+ "license": "UNLICENSED",
29
+ "engines": {
30
+ "node": ">=24"
31
+ },
32
+ "peerDependencies": {
33
+ "react": ">=18"
34
+ },
35
+ "devDependencies": {
36
+ "@eslint/js": "^10.0.1",
37
+ "@testing-library/jest-dom": "^7.0.1",
38
+ "@testing-library/react": "^16.3.3",
39
+ "@testing-library/user-event": "^14.6.7",
40
+ "@vitejs/plugin-react": "^6.1.1",
41
+ "esbuild": "^0.28.0",
42
+ "eslint": "^10.12.0",
43
+ "eslint-plugin-react-hooks": "^7.1.1",
44
+ "globals": "^17.13.0",
45
+ "jsdom": "^29.1.1",
46
+ "react": "^19.3.0",
47
+ "react-dom": "^19.3.0",
48
+ "sass-embedded": "^1.105.1",
49
+ "vite": "^8.3.2",
50
+ "vitest": "^4.1.11"
51
+ },
52
+ "publishConfig": {
53
+ "registry": "https://registry.npmjs.org",
54
+ "access": "public"
55
+ },
56
+ "dependencies": {
57
+ "@carissa-io/components-icon": "^0.1.2"
58
+ }
59
+ }
@@ -0,0 +1,185 @@
1
+ @use 'variables' as *;
2
+
3
+ // `center` centres; anything else is `start`.
4
+ $_text-align: start;
5
+ $_items: flex-start;
6
+
7
+ @if $c-testimonials-align == center {
8
+ $_text-align: center;
9
+ $_items: center;
10
+ }
11
+
12
+ .c-testimonials {
13
+ box-sizing: border-box;
14
+ padding-block: $c-testimonials-padding-block;
15
+ padding-inline: $c-testimonials-padding-inline;
16
+ font-family: $c-testimonials-font-family;
17
+ color: $c-testimonials-color;
18
+ background: $c-testimonials-background;
19
+ }
20
+
21
+ .c-testimonials a {
22
+ color: $c-testimonials-link-color;
23
+ text-decoration: $c-testimonials-link-text-decoration;
24
+ }
25
+
26
+ .c-testimonials__header {
27
+ max-width: $c-testimonials-max-width;
28
+ margin-inline: auto;
29
+ margin-block-end: $c-testimonials-header-margin-bottom;
30
+ text-align: $_text-align;
31
+ }
32
+
33
+ .c-testimonials__title {
34
+ margin: 0;
35
+ max-width: $c-testimonials-title-max-width;
36
+ font-size: $c-testimonials-title-font-size;
37
+ font-weight: $c-testimonials-title-font-weight;
38
+ line-height: $c-testimonials-title-line-height;
39
+ color: $c-testimonials-title-color;
40
+ overflow-wrap: break-word;
41
+
42
+ // The title box has its own max-width, so a centred header centres the box as well as its text
43
+ @if $c-testimonials-align == center {
44
+ margin-inline: auto;
45
+ }
46
+ }
47
+
48
+ .c-testimonials__figure {
49
+ box-sizing: border-box;
50
+ display: flex;
51
+ flex-direction: column;
52
+ gap: $c-testimonials-gap;
53
+ max-width: $c-testimonials-max-width;
54
+ margin: 0;
55
+ margin-inline: auto;
56
+ align-items: $_items;
57
+ text-align: $_text-align;
58
+ }
59
+
60
+ @keyframes c-testimonials-fade {
61
+ from {
62
+ opacity: 0;
63
+ }
64
+
65
+ to {
66
+ opacity: 1;
67
+ }
68
+ }
69
+
70
+ // Opacity only: no movement and no layout shift. Added by the component only after the user changed the item.
71
+ .c-testimonials__figure--enter {
72
+ animation: c-testimonials-fade $c-testimonials-fade-duration $c-testimonials-fade-easing;
73
+ }
74
+
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .c-testimonials__figure--enter {
77
+ animation: none;
78
+ }
79
+ }
80
+
81
+ .c-testimonials__quote {
82
+ margin: 0;
83
+ max-width: $c-testimonials-quote-max-width;
84
+ font-size: $c-testimonials-quote-font-size;
85
+ font-weight: $c-testimonials-quote-font-weight;
86
+ line-height: $c-testimonials-quote-line-height;
87
+ letter-spacing: $c-testimonials-quote-letter-spacing;
88
+ color: $c-testimonials-quote-color;
89
+ overflow-wrap: break-word;
90
+ }
91
+
92
+ .c-testimonials__cite {
93
+ display: flex;
94
+ flex-wrap: wrap;
95
+ align-items: center;
96
+ justify-content: $_items;
97
+ gap: $c-testimonials-cite-gap;
98
+ min-width: 0;
99
+ }
100
+
101
+ .c-testimonials__avatar {
102
+ flex: none;
103
+ display: block;
104
+ max-width: $c-testimonials-avatar-size;
105
+ max-height: $c-testimonials-avatar-size;
106
+ overflow: visible;
107
+ }
108
+
109
+ .c-testimonials__avatar > * {
110
+ max-width: 100%;
111
+ height: auto;
112
+ }
113
+
114
+ .c-testimonials__person {
115
+ display: flex;
116
+ flex-direction: column;
117
+ align-items: $_items;
118
+ gap: $c-testimonials-person-gap;
119
+ min-width: 0;
120
+ }
121
+
122
+ .c-testimonials__name {
123
+ font-size: $c-testimonials-name-font-size;
124
+ font-weight: $c-testimonials-name-font-weight;
125
+ color: $c-testimonials-name-color;
126
+ overflow-wrap: break-word;
127
+ }
128
+
129
+ .c-testimonials__role {
130
+ font-size: $c-testimonials-role-font-size;
131
+ color: $c-testimonials-role-color;
132
+ overflow-wrap: break-word;
133
+ }
134
+
135
+ .c-testimonials__source {
136
+ font-size: $c-testimonials-source-font-size;
137
+ color: $c-testimonials-source-color;
138
+ overflow-wrap: break-word;
139
+ }
140
+
141
+ .c-testimonials__controls {
142
+ display: flex;
143
+ flex-wrap: wrap;
144
+ align-items: center;
145
+ justify-content: $_items;
146
+ gap: $c-testimonials-controls-gap;
147
+ max-width: $c-testimonials-max-width;
148
+ margin-block-start: $c-testimonials-controls-margin-top;
149
+ margin-inline: auto;
150
+ }
151
+
152
+ .c-testimonials__button {
153
+ display: inline-flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ box-sizing: border-box;
157
+ padding-block: $c-testimonials-button-padding-block;
158
+ padding-inline: $c-testimonials-button-padding-inline;
159
+ font-family: $c-testimonials-button-font-family;
160
+ font-size: $c-testimonials-button-font-size;
161
+ font-weight: $c-testimonials-button-font-weight;
162
+ color: $c-testimonials-button-color;
163
+ background: $c-testimonials-button-background;
164
+ border: $c-testimonials-button-border-width $c-testimonials-button-border-style $c-testimonials-button-border-color;
165
+ border-radius: $c-testimonials-button-radius;
166
+ cursor: $c-testimonials-button-cursor;
167
+ }
168
+
169
+ .c-testimonials__counter {
170
+ font-size: $c-testimonials-counter-font-size;
171
+ color: $c-testimonials-counter-color;
172
+ }
173
+
174
+ // Visually hidden, still announced: the polite live region that receives the counter after the user moves.
175
+ .c-testimonials__status {
176
+ position: absolute;
177
+ margin: -1px;
178
+ padding: 0;
179
+ border: 0;
180
+ width: 1px;
181
+ height: 1px;
182
+ clip-path: inset(50%);
183
+ white-space: normal;
184
+ overflow: hidden;
185
+ }
@@ -0,0 +1,73 @@
1
+ // Every value is `!default`: set it before the first @use / through `with (...)` and it wins.
2
+ //
3
+ // @use '@carissa-io/webkit-testimonials/scss' with ($c-testimonials-quote-font-size: 2.5rem, $c-testimonials-button-border-width: 1px);
4
+ //
5
+ // The defaults are neutral: inherited colours, a transparent background, no card, no shadow, no gradient, no icon and no
6
+ // motion (the fade of the quote has a 0s duration until a theme sets one). A theme gives the look (sizes from its type scale, `var(--c-*)` colours, the 1px rules).
7
+
8
+ // Root and container
9
+ $c-testimonials-font-family: inherit !default;
10
+ $c-testimonials-color: inherit !default;
11
+ $c-testimonials-background: transparent !default;
12
+ $c-testimonials-max-width: 80rem !default; // the inner block, centred
13
+ $c-testimonials-padding-block: 4rem !default;
14
+ $c-testimonials-padding-inline: 1rem !default;
15
+
16
+ // Alignment: `start` or `center`. It sets text-align and the flex alignment of the figure, the attribution and the controls.
17
+ $c-testimonials-align: start !default;
18
+
19
+ // Header (title)
20
+ $c-testimonials-header-margin-bottom: 2rem !default;
21
+ $c-testimonials-title-font-size: 1.75rem !default;
22
+ $c-testimonials-title-font-weight: inherit !default;
23
+ $c-testimonials-title-line-height: 1.15 !default;
24
+ $c-testimonials-title-color: inherit !default;
25
+ $c-testimonials-title-max-width: 40ch !default;
26
+
27
+ // The figure: the quote and its attribution
28
+ $c-testimonials-gap: 1.5rem !default; // between the quote and the attribution
29
+ $c-testimonials-quote-font-size: 2rem !default; // the theme ties it to its heading size
30
+ $c-testimonials-quote-font-weight: inherit !default;
31
+ $c-testimonials-quote-line-height: 1.3 !default;
32
+ $c-testimonials-quote-letter-spacing: normal !default;
33
+ $c-testimonials-quote-color: inherit !default;
34
+ $c-testimonials-quote-max-width: 65ch !default; // the text measure
35
+
36
+ // Fade of the new quote after the user pressed previous / next (never on mount; cancelled under prefers-reduced-motion).
37
+ // 0s keeps the piece inert; a theme sets a duration.
38
+ $c-testimonials-fade-duration: 0s !default;
39
+ $c-testimonials-fade-easing: ease !default;
40
+
41
+ // Attribution (figcaption)
42
+ $c-testimonials-cite-gap: 1rem !default; // between the avatar and the person
43
+ $c-testimonials-avatar-size: 3rem !default; // the box the site's avatar node sits in
44
+ $c-testimonials-person-gap: 0.125rem !default;
45
+ $c-testimonials-name-font-size: 1rem !default;
46
+ $c-testimonials-name-font-weight: inherit !default;
47
+ $c-testimonials-name-color: inherit !default;
48
+ $c-testimonials-role-font-size: 0.875rem !default;
49
+ $c-testimonials-role-color: inherit !default;
50
+ $c-testimonials-source-font-size: 0.875rem !default;
51
+ $c-testimonials-source-color: inherit !default;
52
+
53
+ // Controls: previous button, counter, next button
54
+ $c-testimonials-controls-gap: 1rem !default;
55
+ $c-testimonials-controls-margin-top: 2rem !default;
56
+ $c-testimonials-button-padding-block: 0.5rem !default;
57
+ $c-testimonials-button-padding-inline: 1rem !default;
58
+ $c-testimonials-button-font-family: inherit !default;
59
+ $c-testimonials-button-font-size: 0.875rem !default;
60
+ $c-testimonials-button-font-weight: inherit !default;
61
+ $c-testimonials-button-color: inherit !default;
62
+ $c-testimonials-button-background: transparent !default;
63
+ $c-testimonials-button-border-width: 0 !default; // the theme sets 1px
64
+ $c-testimonials-button-border-style: solid !default;
65
+ $c-testimonials-button-border-color: currentcolor !default;
66
+ $c-testimonials-button-radius: 0 !default;
67
+ $c-testimonials-button-cursor: pointer !default;
68
+ $c-testimonials-counter-font-size: 0.875rem !default;
69
+ $c-testimonials-counter-color: inherit !default;
70
+
71
+ // Links the site puts in a slot (quote, role, source): they follow the piece colours, never the browser blue
72
+ $c-testimonials-link-color: inherit !default;
73
+ $c-testimonials-link-text-decoration: underline !default;
@@ -0,0 +1,2 @@
1
+ @forward 'variables';
2
+ @use 'testimonials';