@pokela/components 2.5.2 → 2.6.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.
@@ -0,0 +1,45 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * ExplodedView: räjäytyskuva. Kerrokset levyinä 3D-tilassa, päällekkäin
4
+ * tai viuhkana, niin että näkee mistä valmis pinta koostuu.
5
+ *
6
+ * Kerrokset ovat oikeaa HTML:ää, eivät kuvia: teksti pysyy terävänä ja
7
+ * sisältö on samaa koodia kuin muualla. Ei WebGL:ää eikä kirjastoja,
8
+ * pelkät CSS 3D -muunnokset.
9
+ *
10
+ * - Osoitin kääntää pinoa ja erkanee kerroksia.
11
+ * - Elementit, joilla on sama `data-kohde`, syttyvät yhdessä: osoitin
12
+ * komponentin päällä näyttää sen jokaisessa kerroksessa.
13
+ * - Klikkaus nostaa kerroksen kerrallaan esiin ja lopuksi palauttaa
14
+ * koko pinon.
15
+ * - Tilassa `auki` vieritys kokoaa kerrokset yhdeksi suoraksi
16
+ * pinnaksi, kun kuva poistuu näkymän yläreunasta.
17
+ *
18
+ * Silmukka pyörii vain kun jokin liikkuu, ja pysähtyy kun kuva ei näy.
19
+ * Pysyvää liikettä ei ole, joten taukonappia ei tarvita (WCAG 2.2.2).
20
+ * Vähemmän liikettä pyytäneelle pino näkyy paikallaan alkutilassaan.
21
+ *
22
+ * Kuva on koriste, jonka sisältö on muualla sivulla: ruudunlukija lukee
23
+ * vain `label`-kuvauksen. Kerroksiin ei laiteta linkkejä eikä nappeja:
24
+ * ne olisivat näppäimistölle piilotettuja kohteita.
25
+ *
26
+ * `Tila` on Figmassa variantti: `auki` on pino viuhkana, `koottu`
27
+ * kerrokset yhdeksi pinnaksi koottuna. Ks. ExplodedView.figma.ts.
28
+ */
29
+ export type ExplodedTila = 'auki' | 'koottu';
30
+ export type ExplodedLayer = {
31
+ /** Kerroksen nimi levyn yläpuolella, esim. "01 Tokenit". */
32
+ name: string;
33
+ /** Mistä kerros tulee, esim. "tokens.json". Valinnainen. */
34
+ source?: string;
35
+ content: ReactNode;
36
+ };
37
+ export default function ExplodedView({ layers, label, tila, className, }: {
38
+ /** Alimmasta ylimpään. Ylin on valmis pinta. */
39
+ layers: ExplodedLayer[];
40
+ /** Kuvan kuvaus ruudunlukijalle. */
41
+ label: string;
42
+ /** Alkutila. `koottu` näyttää valmiin pinnan; klikkaus avaa pinon. */
43
+ tila?: ExplodedTila;
44
+ className?: string;
45
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,172 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef } from 'react';
4
+ const lerp = (a, b, k) => a + (b - a) * k;
5
+ export default function ExplodedView({ layers, label, tila = 'auki', className, }) {
6
+ const kehys = useRef(null);
7
+ const pino = useRef(null);
8
+ useEffect(() => {
9
+ const hero = kehys.current;
10
+ const el = pino.current;
11
+ if (!hero || !el)
12
+ return;
13
+ const kerrokset = [...el.querySelectorAll('.exploded__layer')];
14
+ const vahemman = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
15
+ const n = kerrokset.length;
16
+ /* Levyn mitat luetaan CSS:stä, jotta ne ovat yhdessä paikassa. */
17
+ const levyL = el.offsetWidth || 960;
18
+ const levyK = el.offsetHeight || 600;
19
+ /* x, y: osoitin -1…1 jousella. p: 1 = auki, 0 = koottu. */
20
+ const t = {
21
+ x: 0, y: 0, vx: 0, vy: 0, kx: 0, ky: 0,
22
+ p: tila === 'auki' ? 1 : 0,
23
+ auki: tila === 'auki',
24
+ vieritys: 1,
25
+ nakyy: true, raf: 0, edellinen: 0,
26
+ mitta: 1, kapea: false,
27
+ };
28
+ function koko() {
29
+ const r = hero.getBoundingClientRect();
30
+ t.kapea = r.width < 560;
31
+ /* Vino pino vie enemmän tilaa kuin levy itse. */
32
+ const tarveL = levyL * (t.kapea ? 1.3 : 2.1);
33
+ const tarveK = levyK * (t.kapea ? 2.6 : 2.1);
34
+ t.mitta = Math.min(r.width / tarveL, r.height / tarveK);
35
+ piirra();
36
+ }
37
+ function piirra() {
38
+ const p = t.p;
39
+ const rx = t.kapea ? lerp(0, 58, p) - t.y * 6 * p : lerp(0, 12, p) - t.y * 8 * p;
40
+ const ry = t.kapea ? t.x * 3 * p : lerp(0, 52, p) + t.x * 12 * p;
41
+ const rz = t.kapea ? t.x * 4 * p : lerp(0, -4, p);
42
+ const vali = (t.kapea ? 0.36 : 0.42) * levyL * p * (1 + 0.1 * Math.abs(t.x));
43
+ const r = hero.getBoundingClientRect();
44
+ const tayttava = Math.min(r.width / levyL, r.height / levyK) * 0.92;
45
+ el.style.setProperty('--rx', `${rx}deg`);
46
+ el.style.setProperty('--ry', `${ry}deg`);
47
+ el.style.setProperty('--rz', `${rz}deg`);
48
+ el.style.setProperty('--vali', `${vali}px`);
49
+ el.style.setProperty('--nosto', `${(t.kapea ? 0 : 0.05) * levyK * p}px`);
50
+ el.style.setProperty('--s', String(lerp(tayttava, t.mitta, p)));
51
+ /* Koottuna alemmat levyt piiloon, ettei niiden reuna näy ylimmän alta. */
52
+ kerrokset.forEach((k, i) => k.classList.toggle('exploded__layer--piilossa', p < 0.02 && i < n - 1));
53
+ }
54
+ const kohde = () => (t.auki ? t.vieritys : 0);
55
+ function vaihe(nyt) {
56
+ t.raf = 0;
57
+ const dt = Math.min(50, t.edellinen ? nyt - t.edellinen : 16.7);
58
+ t.edellinen = nyt;
59
+ const f = dt / 16.7;
60
+ const k = 0.07 * f;
61
+ t.vx = (t.vx + (t.kx - t.x) * k) * Math.pow(0.8, f);
62
+ t.vy = (t.vy + (t.ky - t.y) * k) * Math.pow(0.8, f);
63
+ t.x += t.vx * f;
64
+ t.y += t.vy * f;
65
+ t.p += (kohde() - t.p) * Math.min(1, 0.12 * f);
66
+ piirra();
67
+ const lepaa = Math.abs(t.vx) + Math.abs(t.vy) + Math.abs(t.kx - t.x) + Math.abs(t.ky - t.y) + Math.abs(kohde() - t.p) <
68
+ 0.0005;
69
+ if (t.nakyy && !document.hidden && !lepaa)
70
+ t.raf = requestAnimationFrame(vaihe);
71
+ else
72
+ t.edellinen = 0;
73
+ }
74
+ const herata = () => {
75
+ if (!t.raf && t.nakyy)
76
+ t.raf = requestAnimationFrame(vaihe);
77
+ };
78
+ const asetu = () => {
79
+ t.p = kohde();
80
+ piirra();
81
+ };
82
+ const liiku = vahemman ? asetu : herata;
83
+ const osoitin = (e) => {
84
+ if (e.pointerType !== 'mouse' && e.pointerType !== 'pen')
85
+ return;
86
+ const r = hero.getBoundingClientRect();
87
+ if (e.clientY < r.top - r.height || e.clientY > r.bottom + r.height)
88
+ return;
89
+ t.kx = Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width) * 2 - 1));
90
+ t.ky = Math.max(-1, Math.min(1, ((e.clientY - r.top) / r.height) * 2 - 1));
91
+ herata();
92
+ };
93
+ const poistu = () => {
94
+ t.kx = 0;
95
+ t.ky = 0;
96
+ herata();
97
+ };
98
+ /* Kohdistus: klikkaus kiertää kerrokset alimmasta ylimpään. Koottu
99
+ pino avautuu ensin, ja kierroksen lopussa palaa alkutilaansa. */
100
+ let aktiivinen = -1;
101
+ function kohdista(i) {
102
+ aktiivinen = i;
103
+ el.classList.toggle('exploded__stack--kohdistus', i >= 0);
104
+ kerrokset.forEach((k, j) => k.classList.toggle('exploded__layer--aktiivinen', j === i));
105
+ }
106
+ const klikkaus = () => {
107
+ if (!t.auki) {
108
+ t.auki = true;
109
+ kohdista(-1);
110
+ }
111
+ else if (aktiivinen >= n - 1) {
112
+ kohdista(-1);
113
+ if (tila === 'koottu')
114
+ t.auki = false;
115
+ }
116
+ else {
117
+ kohdista(aktiivinen + 1);
118
+ }
119
+ liiku();
120
+ };
121
+ const valaise = (e) => {
122
+ el.querySelectorAll('.exploded--valaistu').forEach((x) => x.classList.remove('exploded--valaistu'));
123
+ const k = e.target.closest('[data-kohde]');
124
+ if (!k)
125
+ return;
126
+ el.querySelectorAll(`[data-kohde="${k.dataset.kohde}"]`).forEach((x) => x.classList.add('exploded--valaistu'));
127
+ };
128
+ const sammuta = () => el.querySelectorAll('.exploded--valaistu').forEach((x) => x.classList.remove('exploded--valaistu'));
129
+ /* Vieritys kokoaa, kun kuva poistuu yläreunasta. */
130
+ const vieritys = () => {
131
+ const r = hero.getBoundingClientRect();
132
+ const q = Math.max(0, Math.min(1, -r.top / (r.height * 0.7)));
133
+ t.vieritys = 1 - q * q * (3 - 2 * q);
134
+ if (t.auki && tila === 'auki')
135
+ liiku();
136
+ };
137
+ const nakyvyys = new IntersectionObserver(([e]) => {
138
+ t.nakyy = e.isIntersecting;
139
+ if (t.nakyy)
140
+ herata();
141
+ });
142
+ nakyvyys.observe(hero);
143
+ const mitat = new ResizeObserver(koko);
144
+ mitat.observe(hero);
145
+ if (!vahemman) {
146
+ window.addEventListener('pointermove', osoitin, { passive: true });
147
+ hero.addEventListener('pointerleave', poistu);
148
+ window.addEventListener('scroll', vieritys, { passive: true });
149
+ }
150
+ hero.addEventListener('click', klikkaus);
151
+ el.addEventListener('pointerover', valaise);
152
+ el.addEventListener('pointerleave', sammuta);
153
+ koko();
154
+ if (!vahemman)
155
+ vieritys();
156
+ asetu();
157
+ /* Ennen tätä pinolla ei ole mittoja, joten se on piilossa. */
158
+ hero.classList.add('exploded--valmis');
159
+ return () => {
160
+ cancelAnimationFrame(t.raf);
161
+ nakyvyys.disconnect();
162
+ mitat.disconnect();
163
+ window.removeEventListener('pointermove', osoitin);
164
+ hero.removeEventListener('pointerleave', poistu);
165
+ window.removeEventListener('scroll', vieritys);
166
+ hero.removeEventListener('click', klikkaus);
167
+ el.removeEventListener('pointerover', valaise);
168
+ el.removeEventListener('pointerleave', sammuta);
169
+ };
170
+ }, [tila]);
171
+ return (_jsx("div", { ref: kehys, className: ['exploded', `exploded--${tila}`, className].filter(Boolean).join(' '), role: "img", "aria-label": label, children: _jsx("div", { className: "exploded__stage", "aria-hidden": "true", children: _jsx("div", { ref: pino, className: "exploded__stack", children: layers.map((layer, i) => (_jsxs("div", { className: "exploded__layer", style: { '--i': i }, children: [_jsx("div", { className: "exploded__plate", children: layer.content }), _jsxs("div", { className: "exploded__name", children: [_jsx("span", { className: "meta ink", children: layer.name }), layer.source ? _jsx("span", { className: "meta meta--s", children: layer.source }) : null] })] }, layer.name))) }) }) }));
172
+ }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pokela/components
2
2
 
3
- React-komponentit: **Pokela Design System**, versio 2.5.2.
3
+ React-komponentit: **Pokela Design System**, versio 2.6.1.
4
4
 
5
5
  > Generoitu hakemistosta `components/` — samoista tiedostoista joita sivusto
6
6
  > ajaa. Älä muokkaa käsin: muutokset tehdään lähteeseen ja paketti
@@ -22,6 +22,7 @@ tyylit saa myös suoraan: `@pokela/components/styles.css`.
22
22
  - `ListRow`
23
23
  - `Accordion`
24
24
  - `Timeline`
25
+ - `ExplodedView`
25
26
  - `Grid`
26
27
  - `Reveal`
27
28
  - `ThemeScript`
@@ -44,6 +45,7 @@ import Link from 'next/link';
44
45
  - **Nav, Footer** — tarvitsevat sivuston sanakirjan ja reitityksen
45
46
  - **BeforeAfter, Video** — rakentuvat Median varaan, ja Media lukee kuvamanifestia
46
47
  - **HeroName** — piirtää sivuston omaa nimeä fontin ääriviivoista (`@/content/nimi.generated.json`)
48
+ - **SivuKerroksina** — kokoaa tämän sivuston räjäytyskuvan sen omista tokeneista ja sisällöstä; yleinen osa on ExplodedView
47
49
  - **HeroIntro, HeroIntroLiike** — ovat etusivun intro: sivuston ominaisuus, ei design systemin osa
48
50
  - **ComponentView, PrintCv** — ovat riippumattomia mutta sivuston ominaisuuksia — lähdekoodinäkymä ja CV:n tulostusasu — eivät design systemin osia
49
51
 
@@ -54,6 +56,7 @@ import Link from 'next/link';
54
56
  - `styles/list-row.css`
55
57
  - `styles/accordion.css`
56
58
  - `styles/timeline.css`
59
+ - `styles/exploded-view.css`
57
60
 
58
61
  Luokat ovat globaaleja, eivät CSS-moduuleja. Se on tarkoituksellista: sama
59
62
  luokka on myös Figmassa komponentin nimenä, ja hajautettu nimi katkaisisi
@@ -7,6 +7,7 @@ Use these instead of building an equivalent.
7
7
  - `ListRow`
8
8
  - `Accordion`, `useAccordionGroup`
9
9
  - `Timeline`, `TimelineItem`
10
+ - `ExplodedView`
10
11
  - `Grid`, `Col`
11
12
  - `Reveal`
12
13
  - `ThemeScript`, `THEME_STORAGE_KEY`
@@ -46,4 +47,22 @@ the total duration.
46
47
  </Timeline>
47
48
  ```
48
49
 
50
+ `ExplodedView` shows layers as plates in 3D space: how a finished surface is
51
+ built from its parts. Each layer is real markup, not an image. Give elements
52
+ the same `data-kohde` in several layers and they light up together on hover.
53
+ `tila="koottu"` starts with the layers collapsed into one surface. Put no
54
+ links or buttons inside the layers; the whole view is one image to a screen
55
+ reader, described by `label`.
56
+
57
+ ```jsx
58
+ <ExplodedView
59
+ label="The page as three layers: tokens, components and the finished page."
60
+ layers={[
61
+ { name: '01 Tokens', source: 'tokens.json', content: <Tokens /> },
62
+ { name: '02 Components', content: <Components /> },
63
+ { name: '03 Page', content: <Page /> },
64
+ ]}
65
+ />
66
+ ```
67
+
49
68
  `Reveal` fades its children in on scroll. Wrap sections, not single elements.
package/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export { Accordion, useAccordionGroup } from './Accordion.js';
7
7
  export type { AccordionProps } from './Accordion.js';
8
8
  export { default as Timeline, TimelineItem } from './Timeline.js';
9
9
  export type { TimelineVariant } from './Timeline.js';
10
+ export { default as ExplodedView } from './ExplodedView.js';
11
+ export type { ExplodedTila, ExplodedLayer } from './ExplodedView.js';
10
12
  export { Grid, Col } from './Grid.js';
11
13
  export { default as Reveal } from './Reveal.js';
12
14
  export { default as ThemeScript, THEME_STORAGE_KEY } from './ThemeScript.js';
package/index.js CHANGED
@@ -4,6 +4,7 @@ export { default as Icon, ICON_NAMES } from './Icon.js';
4
4
  export { default as ListRow } from './ListRow.js';
5
5
  export { Accordion, useAccordionGroup } from './Accordion.js';
6
6
  export { default as Timeline, TimelineItem } from './Timeline.js';
7
+ export { default as ExplodedView } from './ExplodedView.js';
7
8
  export { Grid, Col } from './Grid.js';
8
9
  export { default as Reveal } from './Reveal.js';
9
10
  export { default as ThemeScript, THEME_STORAGE_KEY } from './ThemeScript.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pokela/components",
3
- "version": "2.5.2",
3
+ "version": "2.6.1",
4
4
  "description": "React-komponentit: Pokela Design System. Generoitu tiedostoista components/, ei käsin ylläpidetty.",
5
5
  "license": "MIT",
6
6
  "author": "Veli-Matti Pokela",
@@ -27,7 +27,7 @@
27
27
  "LICENSE"
28
28
  ],
29
29
  "dependencies": {
30
- "@pokela/tokens": "^2.5.2"
30
+ "@pokela/tokens": "^2.6.1"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": ">=18"
@@ -0,0 +1,77 @@
1
+ /* Räjäytyskuva (components/ExplodedView.tsx). Levyt erottaa toisistaan
2
+ viiva ja taustan sävy, ei varjo: järjestelmässä ei ole varjoja.
3
+ Näyttämö on kiinteän kokoinen ja skaalataan
4
+ paikkaansa (--s), joten levyn sisältö käyttää sivun omia mittoja.
5
+ Kulmat, väli ja mittakaava tulevat skriptistä. */
6
+ .exploded {
7
+ /* Levyn mitat: näyttämön koordinaatisto, ei layout-mitta. */
8
+ --levy-leveys: 960px;
9
+ --levy-korkeus: 600px;
10
+ position: relative;
11
+ aspect-ratio: 4 / 5;
12
+ background: var(--badge-surface);
13
+ overflow: hidden;
14
+ cursor: pointer;
15
+ touch-action: pan-y;
16
+ -webkit-tap-highlight-color: transparent;
17
+ }
18
+ @media (min-width: 600px) { .exploded { aspect-ratio: 16 / 9; } }
19
+ @media (min-width: 900px) { .exploded { aspect-ratio: 21 / 9; } }
20
+
21
+ .exploded__stage {
22
+ position: absolute; inset: 0;
23
+ perspective: calc(var(--levy-leveys) * 2.3);
24
+ perspective-origin: 50% 40%;
25
+ opacity: 0;
26
+ transition: opacity var(--dur-slow) var(--ease-standard);
27
+ }
28
+ .exploded--valmis .exploded__stage { opacity: 1; }
29
+
30
+ .exploded__stack {
31
+ --rx: 0deg; --ry: 0deg; --rz: 0deg; --vali: 0px; --nosto: 0px; --s: 1;
32
+ position: absolute; left: 50%; top: 50%;
33
+ width: var(--levy-leveys); height: var(--levy-korkeus);
34
+ margin-left: calc(var(--levy-leveys) / -2);
35
+ margin-top: calc(var(--levy-korkeus) / -2);
36
+ transform-style: preserve-3d;
37
+ transform: scale(var(--s)) translateY(var(--nosto)) rotateX(var(--rx)) rotateY(var(--ry)) rotateZ(var(--rz))
38
+ translateZ(calc(var(--vali) * -1));
39
+ }
40
+
41
+ .exploded__layer {
42
+ --i: 0;
43
+ position: absolute; inset: 0;
44
+ transform-style: preserve-3d;
45
+ transform: translateZ(calc(var(--i) * var(--vali) + var(--korotus, 0px)));
46
+ transition: transform var(--dur-base) var(--ease-out);
47
+ }
48
+ .exploded__layer--aktiivinen { --korotus: var(--space-40); }
49
+ .exploded__layer--piilossa { visibility: hidden; }
50
+
51
+ .exploded__plate {
52
+ position: absolute; inset: 0; overflow: hidden;
53
+ background: var(--paper); color: var(--ink);
54
+ border: var(--hairline) solid var(--line);
55
+ transition: opacity var(--dur-slow) var(--ease-standard);
56
+ }
57
+ .exploded__name {
58
+ position: absolute; left: 0; bottom: 100%;
59
+ display: grid; gap: var(--space-4);
60
+ padding-bottom: var(--space-16);
61
+ white-space: nowrap;
62
+ transition: opacity var(--dur-slow) var(--ease-standard);
63
+ }
64
+
65
+ /* Lähde on taustapinnalla, ei paperilla: ink-faint jää siinä alle 4.5:1. */
66
+ .exploded__name .meta--s { color: var(--ink-muted); }
67
+
68
+ .exploded__stack--kohdistus .exploded__layer:not(.exploded__layer--aktiivinen) .exploded__plate,
69
+ .exploded__stack--kohdistus .exploded__layer:not(.exploded__layer--aktiivinen) .exploded__name { opacity: 0.35; }
70
+
71
+ /* Sama kohde kaikissa kerroksissa: katkoviiva muuttuu yhtenäiseksi. */
72
+ .exploded [data-kohde] {
73
+ outline: var(--hairline) dashed transparent;
74
+ outline-offset: var(--space-4);
75
+ transition: outline-color var(--dur-fast) var(--ease-standard);
76
+ }
77
+ .exploded--valaistu { outline: var(--hairline-strong) solid var(--ink); }
package/styles.css CHANGED
@@ -6,3 +6,4 @@
6
6
  @import './styles/list-row.css';
7
7
  @import './styles/accordion.css';
8
8
  @import './styles/timeline.css';
9
+ @import './styles/exploded-view.css';