@pokela/components 2.12.0 → 2.13.0

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,6 +1,6 @@
1
1
  # @pokela/components
2
2
 
3
- React-komponentit: **Pokela Design System**, versio 2.12.0.
3
+ React-komponentit: **Pokela Design System**, versio 2.13.0.
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
@@ -31,6 +31,8 @@ tyylit saa myös suoraan: `@pokela/components/styles.css`.
31
31
  - `Switch`
32
32
  - `ChoiceGroup`
33
33
  - `Select`
34
+ - `Tabs`
35
+ - `Tab`
34
36
  - `Grid`
35
37
  - `Reveal`
36
38
  - `ThemeScript`
@@ -69,6 +71,7 @@ import Link from 'next/link';
69
71
  - `styles/text-field.css`
70
72
  - `styles/choice.css`
71
73
  - `styles/select.css`
74
+ - `styles/tabs.css`
72
75
 
73
76
  Luokat ovat globaaleja, eivät CSS-moduuleja. Se on tarkoituksellista: sama
74
77
  luokka on myös Figmassa komponentin nimenä, ja hajautettu nimi katkaisisi
package/Tab.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * Yksi välilehti ja sen paneeli. Käytetään vain `Tabs`in lapsena.
4
+ *
5
+ * Tab ei piirrä itse mitään: `Tabs` lukee sen propsit ja rakentaa
6
+ * niistä välilehtirivin ja paneelit, jotta ARIA-kytkennät (id:t,
7
+ * aria-controls, aria-labelledby) syntyvät yhdessä paikassa. Erillinen
8
+ * komponentti on silti tarpeen, koska kirjoitettuna se on luettavampi
9
+ * kuin taulukko, ja Figman Tabs / Tab -instanssi kääntyy tähän.
10
+ */
11
+ export type TabProps = {
12
+ /** Tunniste, jolla `Tabs` tietää valitun välilehden. */
13
+ value: string;
14
+ /** Välilehden teksti. */
15
+ label: string;
16
+ /** Lukumäärä tekstin perässä, esim. avoimet tehtävät. */
17
+ count?: number;
18
+ disabled?: boolean;
19
+ /** Paneelin sisältö. */
20
+ children?: ReactNode;
21
+ };
22
+ export default function Tab(props: TabProps): import("react/jsx-runtime").JSX.Element;
package/Tab.js ADDED
@@ -0,0 +1,6 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ export default function Tab(props) {
3
+ /* Piirretään vain Tabsin kautta. Yksinään paneelin sisältö näkyy
4
+ sellaisenaan, ettei mitään katoa hiljaa. */
5
+ return _jsx(_Fragment, { children: props.children });
6
+ }
package/Tabs.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Välilehdet: rivi välilehtiä ja valitun paneeli.
4
+ *
5
+ * Sovelluskomponentti (päätökset 22 ja 28). Ulkoasu on navin
6
+ * aktiivisen linkin kieli: valittu välilehti saa musteviivan
7
+ * (--hairline-strong) rivin hiusviivan päälle, muut ovat haaleampia.
8
+ *
9
+ * Rakenne WAI-ARIA APG:n Tabs-mallin mukaan, automaattinen aktivointi:
10
+ *
11
+ * rivi role="tablist", aria-label
12
+ * välilehti role="tab", aria-selected, aria-controls. Vain valittu
13
+ * on sarkainjärjestyksessä (roving tabindex).
14
+ * paneeli role="tabpanel", aria-labelledby, tabindex=0, jotta
15
+ * sarkain vie paneeliin silloinkin, kun siinä ei ole
16
+ * fokusoitavaa.
17
+ *
18
+ * Näppäimet: vasen ja oikea nuoli siirtävät ja valitsevat (rivin
19
+ * päästä toiseen), Home ja End vievät päihin. Pois käytöstä olevat
20
+ * ohitetaan. Paneelit vaihtuvat heti, joten valinta seuraa fokusta.
21
+ */
22
+ export type TabsProps = {
23
+ /** Rivin nimi ruudunlukijalle, esim. "Asetukset". Ei näy. */
24
+ label: string;
25
+ /** Ohjattu valinta. Käytä `onChange`:n kanssa. */
26
+ value?: string;
27
+ /** Alkuvalinta, kun valintaa ei ohjata. Oletus: ensimmäinen käytössä oleva. */
28
+ defaultValue?: string;
29
+ onChange?: (value: string) => void;
30
+ /** `Tab`-elementit. */
31
+ children: ReactNode;
32
+ id?: string;
33
+ className?: string;
34
+ };
35
+ export default function Tabs({ label, value: ohjattu, defaultValue, onChange, children, id: annettuId, className }: TabsProps): import("react/jsx-runtime").JSX.Element;
package/Tabs.js ADDED
@@ -0,0 +1,59 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Children, isValidElement, useId, useRef, useState } from 'react';
4
+ import Tab from './Tab';
5
+ export default function Tabs({ label, value: ohjattu, defaultValue, onChange, children, id: annettuId, className }) {
6
+ const omaId = useId();
7
+ const id = annettuId ?? omaId;
8
+ const tabId = (i) => `${id}-tab-${i}`;
9
+ const paneeliId = (i) => `${id}-paneeli-${i}`;
10
+ const tabit = Children.toArray(children).filter((c) => isValidElement(c) && c.type === Tab);
11
+ const kaytossa = (i) => i >= 0 && i < tabit.length && !tabit[i].props.disabled;
12
+ const [oma, setOma] = useState(defaultValue);
13
+ const pyydetty = ohjattu !== undefined ? ohjattu : oma;
14
+ let valittu = tabit.findIndex((t) => t.props.value === pyydetty);
15
+ if (!kaytossa(valittu))
16
+ valittu = tabit.findIndex((_, i) => kaytossa(i));
17
+ const napit = useRef([]);
18
+ const valitse = (i) => {
19
+ if (!kaytossa(i))
20
+ return;
21
+ const uusi = tabit[i].props.value;
22
+ if (ohjattu === undefined)
23
+ setOma(uusi);
24
+ if (i !== valittu)
25
+ onChange?.(uusi);
26
+ };
27
+ /** Seuraava käytössä oleva suuntaan `askel`, rivin päästä toiseen. */
28
+ const seuraava = (alku, askel) => {
29
+ const n = tabit.length;
30
+ for (let k = 1; k <= n; k++) {
31
+ const i = (((alku + askel * k) % n) + n) % n;
32
+ if (kaytossa(i))
33
+ return i;
34
+ }
35
+ return alku;
36
+ };
37
+ const nappain = (e) => {
38
+ let kohde = -1;
39
+ if (e.key === 'ArrowRight')
40
+ kohde = seuraava(valittu, 1);
41
+ else if (e.key === 'ArrowLeft')
42
+ kohde = seuraava(valittu, -1);
43
+ else if (e.key === 'Home')
44
+ kohde = seuraava(-1, 1);
45
+ else if (e.key === 'End')
46
+ kohde = seuraava(tabit.length, -1);
47
+ else
48
+ return;
49
+ e.preventDefault();
50
+ valitse(kohde);
51
+ napit.current[kohde]?.focus();
52
+ };
53
+ return (_jsxs("div", { className: ['tabs', className].filter(Boolean).join(' '), children: [_jsx("div", { className: "tabs__list", role: "tablist", "aria-label": label, onKeyDown: nappain, children: tabit.map((t, i) => {
54
+ const on = i === valittu;
55
+ return (_jsxs("button", { ref: (el) => {
56
+ napit.current[i] = el;
57
+ }, type: "button", role: "tab", id: tabId(i), className: "tabs__tab", "aria-selected": on, "aria-controls": paneeliId(i), "aria-disabled": t.props.disabled || undefined, tabIndex: on ? 0 : -1, onClick: () => valitse(i), children: [t.props.label, t.props.count !== undefined ? _jsx("span", { className: "tabs__count", children: t.props.count }) : null] }, t.props.value));
58
+ }) }), tabit.map((t, i) => (_jsx("div", { role: "tabpanel", id: paneeliId(i), className: "tabs__panel", "aria-labelledby": tabId(i), tabIndex: 0, hidden: i !== valittu, children: t.props.children }, t.props.value)))] }));
59
+ }
@@ -16,6 +16,8 @@ Use these instead of building an equivalent.
16
16
  - `Switch`
17
17
  - `ChoiceGroup`
18
18
  - `Select`
19
+ - `Tabs`
20
+ - `Tab`
19
21
  - `Grid`, `Col`
20
22
  - `Reveal`
21
23
  - `ThemeScript`, `THEME_STORAGE_KEY`
@@ -128,6 +130,22 @@ work, so do not build a dropdown from a button and a menu.
128
130
  />
129
131
  ```
130
132
 
133
+ `Tabs` switches between views of the same thing, such as the sections of a
134
+ settings page. Give it a `label` for screen readers and one `Tab` per view with
135
+ `value`, `label` and the panel as children; `count` adds a number after the
136
+ label and `disabled` keeps a tab visible but unusable. It follows the WAI-ARIA
137
+ tabs pattern: arrow keys move and select, Home and End jump to the ends, and
138
+ Tab moves into the panel. Do not use it for site navigation (that is `Nav`) or
139
+ for a step-by-step flow.
140
+
141
+ ```jsx
142
+ <Tabs label="Settings" defaultValue="team">
143
+ <Tab value="general" label="General">…</Tab>
144
+ <Tab value="team" label="Team" count={4}>…</Tab>
145
+ <Tab value="billing" label="Billing" disabled>…</Tab>
146
+ </Tabs>
147
+ ```
148
+
131
149
  ## Patterns
132
150
 
133
151
  `PageHeader` and `Section` are patterns: they add no look of their own, only
package/index.d.ts CHANGED
@@ -24,6 +24,10 @@ export { default as ChoiceGroup } from './ChoiceGroup.js';
24
24
  export type { ChoiceGroupProps } from './ChoiceGroup.js';
25
25
  export { default as Select } from './Select.js';
26
26
  export type { SelectOption, SelectProps } from './Select.js';
27
+ export { default as Tabs } from './Tabs.js';
28
+ export type { TabsProps } from './Tabs.js';
29
+ export { default as Tab } from './Tab.js';
30
+ export type { TabProps } from './Tab.js';
27
31
  export { Grid, Col } from './Grid.js';
28
32
  export { default as Reveal } from './Reveal.js';
29
33
  export { default as ThemeScript, THEME_STORAGE_KEY } from './ThemeScript.js';
package/index.js CHANGED
@@ -13,6 +13,8 @@ export { default as Radio } from './Radio.js';
13
13
  export { default as Switch } from './Switch.js';
14
14
  export { default as ChoiceGroup } from './ChoiceGroup.js';
15
15
  export { default as Select } from './Select.js';
16
+ export { default as Tabs } from './Tabs.js';
17
+ export { default as Tab } from './Tab.js';
16
18
  export { Grid, Col } from './Grid.js';
17
19
  export { default as Reveal } from './Reveal.js';
18
20
  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.12.0",
3
+ "version": "2.13.0",
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.12.0"
30
+ "@pokela/tokens": "^2.13.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": ">=18"
@@ -0,0 +1,83 @@
1
+ /* Välilehdet (Tabs). Sovelluskomponentti, sivusto ei käytä tätä.
2
+ ==================================================================
3
+ Navin aktiivisen linkin kieli (päätös 28): rivi välilehtiä
4
+ hiusviivan päällä, valittu saa musteviivan --hairline-strong, joka
5
+ peittää hiusviivan. Valitsematon on --ink-muted ja tummuu hoverissa.
6
+
7
+ Välilehdellä on 8 px sivusisennys, jotta fokusrengas mahtuu sen
8
+ sisään: rivi vierittyy kapealla näytöllä, ja vierittyvä laatikko
9
+ leikkaisi ulkopuolisen renkaan. Sisennys kumotaan rivin
10
+ negatiivisella marginaalilla, joten teksti alkaa palstan reunasta.
11
+ Siksi hiusviiva on taustana eikä reunana: reuna jatkuisi 8 px yli. */
12
+
13
+ .tabs__list {
14
+ display: flex;
15
+ gap: var(--space-16);
16
+ margin-inline: calc(var(--space-8) * -1);
17
+ overflow-x: auto;
18
+ scrollbar-width: none;
19
+ background: linear-gradient(var(--line), var(--line)) no-repeat var(--space-8) 100% /
20
+ calc(100% - var(--space-16)) var(--hairline);
21
+ }
22
+ .tabs__list::-webkit-scrollbar { display: none; }
23
+
24
+ .tabs__tab {
25
+ position: relative;
26
+ flex: none;
27
+ display: inline-flex;
28
+ align-items: center;
29
+ gap: var(--space-8);
30
+ min-height: var(--tap-min);
31
+ padding: 0 var(--space-8);
32
+ margin: 0;
33
+ background: none;
34
+ border: 0;
35
+ font-family: var(--font-ui);
36
+ font-size: var(--text-body-s);
37
+ font-weight: var(--weight-500);
38
+ line-height: 1;
39
+ color: var(--ink-muted);
40
+ white-space: nowrap;
41
+ cursor: pointer;
42
+ }
43
+ @media (prefers-reduced-motion: no-preference) {
44
+ .tabs__tab { transition: color var(--dur-fast) var(--ease-standard); }
45
+ }
46
+ .tabs__tab::after {
47
+ content: '';
48
+ position: absolute;
49
+ left: var(--space-8);
50
+ right: var(--space-8);
51
+ bottom: 0;
52
+ height: var(--hairline-strong);
53
+ }
54
+ .tabs__tab:hover { color: var(--ink); }
55
+ .tabs__tab[aria-selected='true'] { color: var(--ink); }
56
+ .tabs__tab[aria-selected='true']::after { background: var(--ink); }
57
+ .tabs__tab:focus-visible {
58
+ outline: var(--focus-width) solid var(--ink);
59
+ outline-offset: calc(var(--focus-width) * -1);
60
+ }
61
+ .tabs__tab[aria-disabled='true'] { color: var(--ink-faint); cursor: not-allowed; }
62
+
63
+ .tabs__count {
64
+ font-size: var(--text-meta-s);
65
+ font-weight: var(--weight-600);
66
+ font-variant-numeric: tabular-nums;
67
+ color: var(--ink-faint);
68
+ }
69
+ .tabs__tab[aria-selected='true'] .tabs__count { color: var(--ink-muted); }
70
+
71
+ .tabs__panel { padding-top: var(--space-24); }
72
+ .tabs__panel:focus-visible {
73
+ outline: var(--focus-width) solid var(--ink);
74
+ outline-offset: var(--focus-offset);
75
+ }
76
+
77
+ /* Korkean kontrastin tilassa taustat katoavat: valinnan viiva
78
+ piirretään järjestelmän värillä. */
79
+ @media (forced-colors: active) {
80
+ .tabs__tab::after { forced-color-adjust: none; }
81
+ .tabs__tab[aria-selected='true']::after { background: Highlight; }
82
+ .tabs__tab[aria-disabled='true'] { color: GrayText; }
83
+ }
package/styles.css CHANGED
@@ -11,3 +11,4 @@
11
11
  @import './styles/text-field.css';
12
12
  @import './styles/choice.css';
13
13
  @import './styles/select.css';
14
+ @import './styles/tabs.css';