@carissa-io/components-ticker 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,87 @@
1
- # Temporary Holding Version
1
+ # @carissa-io/components-ticker
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
+ Content that scrolls continuously (a ticker, a marquee): logos, headlines, announcements. A React component, SCSS-themed,
4
+ safe on the server. The loop is CSS (a transform, no measuring), it has a real pause control, and under
5
+ `prefers-reduced-motion: reduce` it becomes a static list.
6
+
7
+ ```bash
8
+ npm i @carissa-io/components-ticker
9
+ ```
10
+
11
+ It builds on `@carissa-io/components-animate` (the `use_reduced_motion` and `use_in_view` hooks).
12
+
13
+ ## Use
14
+
15
+ ```jsx
16
+ import { Ticker } from '@carissa-io/components-ticker'
17
+
18
+ <Ticker aria-label="Partners" pause_label="Pause scrolling" play_label="Resume scrolling" duration="40s">
19
+ <img src="/acme.svg" alt="Acme" />
20
+ <img src="/globex.svg" alt="Globex" />
21
+ <a href="/press/1">Carissa wins an award</a>
22
+ </Ticker>
23
+ ```
24
+
25
+ Every child becomes an item (`li`) of a list. The list is rendered twice and the track moves by exactly one list, so the
26
+ loop has no seam. Add enough items for one list to fill the width (a short list is stretched to the viewport width).
27
+
28
+ | Prop | |
29
+ |---|---|
30
+ | `children` | the items |
31
+ | `duration` | time of one full loop: a CSS time (`30s`) or milliseconds; default `$c-ticker-duration`. The speed follows the width of the content (more items, faster for the same duration) |
32
+ | `direction` | `left` (default), `right`, `up` or `down`; anything else falls back to `left`. The vertical ones need a height (`$c-ticker-height`) |
33
+ | `gap` | space between items: pixels (`24`) or a CSS length (`1.5rem`); default `$c-ticker-gap` |
34
+ | `pause_on_hover` | `true` by default: pause while the pointer is over the ticker |
35
+ | `pause_label`, `play_label` | the accessible name of the control while it is playing / paused. Required in practice: the text comes from the app, nothing is built in |
36
+ | `playing`, `default_playing`, `on_playing_change` | controlled / uncontrolled (default `true`) play state; `on_playing_change(playing)` is called when the control is used, also when controlled |
37
+ | `as` | the tag of the root, `div` by default |
38
+ | `className`, `id`, ... | `className` is appended; unknown props go to the root (give it an `aria-label` to name the region) |
39
+
40
+ No text is built in: every label is a prop, and a button without its label prop has no accessible name. The app passes `pause_label` and `play_label` from its own translations, the same rule as `components-carousel`.
41
+
42
+ The root carries `data-state`: `running`, `paused` (by the control), `idle` (off-screen or hidden tab) or `static`
43
+ (reduced motion), and `data-direction`.
44
+
45
+ ## Accessibility
46
+
47
+ - **Pause control:** a real `<button>` (`aria-label` is `pause_label` while playing and `play_label` while paused). Moving
48
+ content must be stoppable (WCAG 2.2.2).
49
+ - **Read once:** the second copy that closes the loop is `aria-hidden` and `inert`, so screen readers and the keyboard see
50
+ the items only once. Links inside the ticker are reachable and the ticker pauses while focus is inside the list.
51
+ - **Hover:** it pauses while the pointer is over it (`pause_on_hover`). Hover is not available on touch screens, so the
52
+ pause control is the way to stop it there.
53
+ - **Pauses by itself** while it is off-screen or the tab is hidden.
54
+
55
+ ## Reduced motion
56
+
57
+ Under `prefers-reduced-motion: reduce` it renders a static list: the items wrap, nothing scrolls, there is no second copy
58
+ and no pause control (the hook and the CSS agree; the app does nothing).
59
+
60
+ ## Style
61
+
62
+ ```scss
63
+ @use '@carissa-io/components-ticker/scss' with (
64
+ $c-ticker-duration: 45s,
65
+ $c-ticker-gap: 3rem
66
+ );
67
+ ```
68
+
69
+ Classes: `c-ticker`, `c-ticker--left|right|up|down`, `c-ticker--horizontal|vertical`, `c-ticker--pause-on-hover`,
70
+ `c-ticker__viewport`, `__track`, `__list` (`--copy`), `__item`, `__toggle` (`--playing|paused`). Every variable is `!default`
71
+ (see [scss/_variables.scss](scss/_variables.scss)); a theme sets the character of the motion with them.
72
+
73
+ | Variable | Default |
74
+ |---|---|
75
+ | `$c-ticker-duration` | `30s` |
76
+ | `$c-ticker-easing` | `linear` |
77
+ | `$c-ticker-gap` | `2rem` |
78
+ | `$c-ticker-height` | `12rem` (vertical) |
79
+ | `$c-ticker-fade-size` | `0` (soft edges off) |
80
+ | `$c-ticker-toggle-size` | `2rem` |
81
+ | `$c-ticker-toggle-color` | `currentcolor` |
82
+ | `$c-ticker-toggle-background` | `transparent` |
83
+ | `$c-ticker-toggle-border-width` | `1px` |
84
+ | `$c-ticker-toggle-border-color` | `currentcolor` |
85
+ | `$c-ticker-toggle-radius` | `50%` |
86
+ | `$c-ticker-toggle-icon-size` | `0.375rem` |
87
+ | `$c-ticker-toggle-focus-outline` | `2px solid currentcolor` |
package/dist/index.js ADDED
@@ -0,0 +1,112 @@
1
+ // src/Ticker.jsx
2
+ import { Children, useState, useSyncExternalStore } from "react";
3
+ import { use_in_view, use_reduced_motion } from "@carissa-io/components-animate";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ var DIRECTIONS = ["left", "right", "up", "down"];
6
+ var TAG = /^[a-z][a-z0-9-]*$/i;
7
+ var TIME = /^\d+(\.\d+)?(ms|s)$/;
8
+ var LENGTH = /^\d+(\.\d+)?(px|rem|em|%)$/;
9
+ var join = (...parts) => parts.filter(Boolean).join(" ");
10
+ function to_time(value) {
11
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return `${value}ms`;
12
+ if (typeof value === "string" && TIME.test(value.trim()) && parseFloat(value) > 0) return value.trim();
13
+ return void 0;
14
+ }
15
+ function to_length(value) {
16
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return `${value}px`;
17
+ if (typeof value === "string" && LENGTH.test(value.trim())) return value.trim();
18
+ return void 0;
19
+ }
20
+ function subscribe_visibility(on_change) {
21
+ if (typeof document === "undefined") return () => {
22
+ };
23
+ document.addEventListener("visibilitychange", on_change);
24
+ return () => document.removeEventListener("visibilitychange", on_change);
25
+ }
26
+ var tab_is_visible = () => typeof document === "undefined" || document.visibilityState !== "hidden";
27
+ var make_inert = (node) => node?.setAttribute("inert", "");
28
+ function Ticker({
29
+ direction = "left",
30
+ duration,
31
+ gap,
32
+ pause_on_hover = true,
33
+ pause_label,
34
+ play_label,
35
+ playing,
36
+ default_playing = true,
37
+ on_playing_change,
38
+ as = "div",
39
+ className = "",
40
+ style,
41
+ children,
42
+ ...rest
43
+ }) {
44
+ const items = Children.toArray(children);
45
+ const safe_direction = DIRECTIONS.includes(direction) ? direction : "left";
46
+ const axis = safe_direction === "up" || safe_direction === "down" ? "vertical" : "horizontal";
47
+ const Tag = typeof as === "string" && TAG.test(as) ? as : "div";
48
+ const reduced = use_reduced_motion();
49
+ const [ref, in_view] = use_in_view();
50
+ const visible_tab = useSyncExternalStore(subscribe_visibility, tab_is_visible, () => true);
51
+ const [own_playing, set_own_playing] = useState(default_playing !== false);
52
+ const wants_play = playing === void 0 ? own_playing : playing !== false;
53
+ const running = wants_play && in_view && visible_tab;
54
+ let state = "running";
55
+ if (reduced) state = "static";
56
+ else if (!wants_play) state = "paused";
57
+ else if (!running) state = "idle";
58
+ const toggle = () => {
59
+ const next = !wants_play;
60
+ if (playing === void 0) set_own_playing(next);
61
+ on_playing_change?.(next);
62
+ };
63
+ const vars = {};
64
+ const safe_duration = to_time(duration);
65
+ const safe_gap = to_length(gap);
66
+ if (safe_duration) vars["--c-ticker-duration"] = safe_duration;
67
+ if (safe_gap) vars["--c-ticker-gap"] = safe_gap;
68
+ const list = (copy) => /* @__PURE__ */ jsx(
69
+ "ul",
70
+ {
71
+ className: join("c-ticker__list", copy && "c-ticker__list--copy"),
72
+ "aria-hidden": copy ? "true" : void 0,
73
+ ref: copy ? make_inert : void 0,
74
+ children: items.map((child, index) => /* @__PURE__ */ jsx("li", { className: "c-ticker__item", children: child }, index))
75
+ }
76
+ );
77
+ return /* @__PURE__ */ jsxs(
78
+ Tag,
79
+ {
80
+ ...rest,
81
+ ref,
82
+ "data-state": state,
83
+ "data-direction": safe_direction,
84
+ style: { ...vars, ...style },
85
+ className: join(
86
+ "c-ticker",
87
+ `c-ticker--${axis}`,
88
+ `c-ticker--${safe_direction}`,
89
+ pause_on_hover !== false && "c-ticker--pause-on-hover",
90
+ className
91
+ ),
92
+ children: [
93
+ /* @__PURE__ */ jsx("div", { className: "c-ticker__viewport", children: /* @__PURE__ */ jsxs("div", { className: "c-ticker__track", children: [
94
+ list(false),
95
+ reduced ? null : list(true)
96
+ ] }) }),
97
+ reduced ? null : /* @__PURE__ */ jsx(
98
+ "button",
99
+ {
100
+ type: "button",
101
+ className: join("c-ticker__toggle", wants_play ? "c-ticker__toggle--playing" : "c-ticker__toggle--paused"),
102
+ "aria-label": wants_play ? pause_label : play_label,
103
+ onClick: toggle
104
+ }
105
+ )
106
+ ]
107
+ }
108
+ );
109
+ }
110
+ export {
111
+ Ticker
112
+ };
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@carissa-io/components-ticker",
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 UI: ticker 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/components-ticker.git"
27
+ },
28
+ "license": "UNLICENSED",
29
+ "engines": {
30
+ "node": ">=24"
31
+ },
32
+ "dependencies": {
33
+ "@carissa-io/components-animate": "^0.1.1"
34
+ },
35
+ "peerDependencies": {
36
+ "react": ">=18"
37
+ },
38
+ "devDependencies": {
39
+ "@eslint/js": "^10.0.1",
40
+ "@testing-library/jest-dom": "^7.0.1",
41
+ "@testing-library/react": "^16.3.3",
42
+ "@testing-library/user-event": "^14.6.7",
43
+ "@vitejs/plugin-react": "^6.1.1",
44
+ "esbuild": "^0.28.0",
45
+ "eslint": "^10.12.0",
46
+ "eslint-plugin-react-hooks": "^7.1.1",
47
+ "globals": "^17.13.0",
48
+ "jsdom": "^29.1.1",
49
+ "react": "^19.3.0",
50
+ "react-dom": "^19.3.0",
51
+ "sass-embedded": "^1.105.1",
52
+ "vite": "^8.3.2",
53
+ "vitest": "^4.1.11"
54
+ },
55
+ "publishConfig": {
56
+ "registry": "https://registry.npmjs.org",
57
+ "access": "public"
58
+ }
59
+ }
@@ -0,0 +1,180 @@
1
+ @use 'variables' as *;
2
+
3
+ @keyframes c-ticker-x {
4
+ from {
5
+ transform: translateX(0);
6
+ }
7
+
8
+ to {
9
+ transform: translateX(-50%);
10
+ }
11
+ }
12
+
13
+ @keyframes c-ticker-y {
14
+ from {
15
+ transform: translateY(0);
16
+ }
17
+
18
+ to {
19
+ transform: translateY(-50%);
20
+ }
21
+ }
22
+
23
+ .c-ticker {
24
+ position: relative;
25
+ display: flex;
26
+ align-items: center;
27
+ gap: $c-ticker-gap;
28
+ }
29
+
30
+ .c-ticker__viewport {
31
+ flex: 1;
32
+ min-width: 0;
33
+ overflow: hidden;
34
+
35
+ @if $c-ticker-fade-size != 0 {
36
+ mask-image: linear-gradient(to right, transparent, #000 $c-ticker-fade-size, #000 calc(100% - #{$c-ticker-fade-size}), transparent);
37
+ }
38
+ }
39
+
40
+ .c-ticker__track {
41
+ display: flex;
42
+ width: max-content;
43
+ animation-duration: var(--c-ticker-duration, #{$c-ticker-duration});
44
+ animation-timing-function: $c-ticker-easing;
45
+ animation-iteration-count: infinite;
46
+ }
47
+
48
+ // Each list is at least as wide as the viewport so a short list never leaves a hole; the padding closes the gap
49
+ // between the end of one list and the start of the next, so the loop moves by exactly one list.
50
+ .c-ticker__list {
51
+ display: flex;
52
+ flex: none;
53
+ gap: var(--c-ticker-gap, #{$c-ticker-gap});
54
+ min-width: 100vw;
55
+ padding: 0 var(--c-ticker-gap, #{$c-ticker-gap}) 0 0;
56
+ margin: 0;
57
+ list-style: none;
58
+ }
59
+
60
+ .c-ticker__item {
61
+ flex: none;
62
+ }
63
+
64
+ .c-ticker--horizontal .c-ticker__track {
65
+ animation-name: c-ticker-x;
66
+ }
67
+
68
+ .c-ticker--right .c-ticker__track,
69
+ .c-ticker--down .c-ticker__track {
70
+ animation-direction: reverse;
71
+ }
72
+
73
+ .c-ticker--vertical {
74
+ .c-ticker__viewport {
75
+ height: $c-ticker-height;
76
+ }
77
+
78
+ .c-ticker__track {
79
+ flex-direction: column;
80
+ width: auto;
81
+ animation-name: c-ticker-y;
82
+ }
83
+
84
+ .c-ticker__list {
85
+ flex-direction: column;
86
+ min-width: 0;
87
+ min-height: $c-ticker-height;
88
+ padding: 0 0 var(--c-ticker-gap, #{$c-ticker-gap}) 0;
89
+ }
90
+ }
91
+
92
+ // Not playing: paused by the control, off-screen or in a hidden tab (and always while focus is inside the list)
93
+ .c-ticker[data-state='paused'] .c-ticker__track,
94
+ .c-ticker[data-state='idle'] .c-ticker__track,
95
+ .c-ticker__viewport:focus-within .c-ticker__track,
96
+ .c-ticker--pause-on-hover .c-ticker__viewport:hover .c-ticker__track {
97
+ animation-play-state: paused;
98
+ }
99
+
100
+ // Reduced motion (the component also renders the static form by itself): items wrap, nothing scrolls
101
+ .c-ticker[data-state='static'] {
102
+ .c-ticker__track {
103
+ width: auto;
104
+ animation: none;
105
+ }
106
+
107
+ .c-ticker__list {
108
+ flex: 0 1 auto;
109
+ flex-wrap: wrap;
110
+ min-width: 0;
111
+ min-height: 0;
112
+ padding: 0;
113
+ }
114
+
115
+ .c-ticker__viewport {
116
+ height: auto;
117
+ mask-image: none;
118
+ }
119
+ }
120
+
121
+ .c-ticker__toggle {
122
+ position: relative;
123
+ box-sizing: border-box;
124
+ flex: none;
125
+ width: $c-ticker-toggle-size;
126
+ height: $c-ticker-toggle-size;
127
+ padding: 0;
128
+ color: $c-ticker-toggle-color;
129
+ cursor: pointer;
130
+ background: $c-ticker-toggle-background;
131
+ border: $c-ticker-toggle-border-width solid $c-ticker-toggle-border-color;
132
+ border-radius: $c-ticker-toggle-radius;
133
+
134
+ &::before {
135
+ position: absolute;
136
+ top: 50%;
137
+ left: 50%;
138
+ content: '';
139
+ }
140
+
141
+ &:focus-visible {
142
+ outline: $c-ticker-toggle-focus-outline;
143
+ outline-offset: 2px;
144
+ }
145
+ }
146
+
147
+ .c-ticker__toggle--playing::before {
148
+ width: $c-ticker-toggle-icon-size;
149
+ height: $c-ticker-toggle-icon-size * 1.5;
150
+ border-right: $c-ticker-toggle-icon-size * 0.375 solid currentcolor;
151
+ border-left: $c-ticker-toggle-icon-size * 0.375 solid currentcolor;
152
+ transform: translate(-50%, -50%);
153
+ }
154
+
155
+ .c-ticker__toggle--paused::before {
156
+ border-top: $c-ticker-toggle-icon-size * 0.75 solid transparent;
157
+ border-bottom: $c-ticker-toggle-icon-size * 0.75 solid transparent;
158
+ border-left: $c-ticker-toggle-icon-size * 1.25 solid currentcolor;
159
+ transform: translate(-35%, -50%);
160
+ }
161
+
162
+ @media (prefers-reduced-motion: reduce) {
163
+ .c-ticker__track {
164
+ width: auto;
165
+ animation: none;
166
+ }
167
+
168
+ .c-ticker__list--copy,
169
+ .c-ticker__toggle {
170
+ display: none;
171
+ }
172
+
173
+ .c-ticker__list {
174
+ flex: 0 1 auto;
175
+ flex-wrap: wrap;
176
+ min-width: 0;
177
+ min-height: 0;
178
+ padding: 0;
179
+ }
180
+ }
@@ -0,0 +1,24 @@
1
+ // Every value is `!default`: set it before the first @use / through `with (...)` and it wins.
2
+ //
3
+ // @use '@carissa-io/components-ticker/scss' with ($c-ticker-duration: 45s, $c-ticker-gap: 3rem);
4
+ //
5
+ // The ticker scrolls by one list per loop, so `$c-ticker-duration` is the time of a full loop: a longer list moves
6
+ // faster for the same duration. Under `prefers-reduced-motion: reduce` it is a static list that wraps.
7
+
8
+ $c-ticker-duration: 30s !default; // one full loop; per instance: `duration` prop or `--c-ticker-duration`
9
+ $c-ticker-easing: linear !default; // a marquee moves at a constant speed
10
+
11
+ // Layout
12
+ $c-ticker-gap: 2rem !default; // between items; per instance: `gap` prop or `--c-ticker-gap`
13
+ $c-ticker-height: 12rem !default; // viewport height of the vertical directions (`up`, `down`)
14
+ $c-ticker-fade-size: 0 !default; // soft edge at both ends of the viewport (a length, `0` is off)
15
+
16
+ // Pause control
17
+ $c-ticker-toggle-size: 2rem !default;
18
+ $c-ticker-toggle-color: currentcolor !default;
19
+ $c-ticker-toggle-background: transparent !default;
20
+ $c-ticker-toggle-border-width: 1px !default;
21
+ $c-ticker-toggle-border-color: currentcolor !default;
22
+ $c-ticker-toggle-radius: 50% !default;
23
+ $c-ticker-toggle-icon-size: 0.375rem !default;
24
+ $c-ticker-toggle-focus-outline: 2px solid currentcolor !default;
@@ -0,0 +1,2 @@
1
+ @forward 'variables';
2
+ @use 'ticker';