@pokela/components 2.9.0 → 2.11.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/Checkbox.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { type InputHTMLAttributes } from 'react';
2
+ /**
3
+ * Valintaruutu: ruutu, label ja ohjeteksti.
4
+ *
5
+ * Sovelluskomponentti (päätös 22), sivusto ei käytä tätä. Kieli on
6
+ * .btn-perheen: hiusviiva --line-strong, ei pyöristystä, valittu ruutu
7
+ * täytetään --inkillä. Rivi on vähintään --tap-min korkea.
8
+ *
9
+ * Oikea <input> on ruudun päällä näkymättömänä, joten näppäimistö,
10
+ * lomake ja ruudunlukija toimivat kuten natiivissa. `indeterminate`
11
+ * ei ole HTML-attribuutti, joten se asetetaan elementille.
12
+ *
13
+ * Virhettä ei ole tässä: se kuuluu ryhmälle (ChoiceGroup), jonka
14
+ * legend kertoo, mitä puuttuu.
15
+ */
16
+ export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'className'> & {
17
+ label: string;
18
+ /** Ohjeteksti labelin alla. */
19
+ hint?: string;
20
+ /** Osittain valittu, esim. "valitse kaikki", kun osa on valittu. */
21
+ indeterminate?: boolean;
22
+ className?: string;
23
+ };
24
+ export default function Checkbox(props: CheckboxProps): import("react/jsx-runtime").JSX.Element;
package/Checkbox.js ADDED
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef } from 'react';
4
+ export default function Checkbox(props) {
5
+ const { label, hint, indeterminate, className, id: annettuId, ...input } = props;
6
+ const omaId = useId();
7
+ const id = annettuId ?? omaId;
8
+ const ohjeId = hint ? `${id}-ohje` : undefined;
9
+ const ref = useRef(null);
10
+ useEffect(() => {
11
+ if (ref.current)
12
+ ref.current.indeterminate = Boolean(indeterminate);
13
+ }, [indeterminate]);
14
+ return (_jsxs("div", { className: ['choice', input.disabled && 'choice--disabled', className].filter(Boolean).join(' '), children: [_jsxs("span", { className: "choice__control", children: [_jsx("input", { ...input, ref: ref, id: id, type: "checkbox", className: "choice__input", "aria-describedby": ohjeId }), _jsx("span", { className: "choice__mark", "aria-hidden": "true", children: _jsxs("svg", { viewBox: "0 0 20 20", children: [_jsx("path", { className: "choice__check", d: "M5 10 L8.5 13.5 L15 6.5" }), _jsx("path", { className: "choice__dash", d: "M5 10 H15" })] }) })] }), _jsx("label", { className: "choice__label", htmlFor: id, children: label }), hint ? (_jsx("p", { id: ohjeId, className: "choice__hint", children: hint })) : null] }));
15
+ }
@@ -0,0 +1,23 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Valintojen ryhmä: otsikko (legend), valinnat, ohje ja virhe.
4
+ *
5
+ * Sovelluskomponentti (päätös 22). Ryhmä on <fieldset>, joten
6
+ * ruudunlukija sanoo otsikon ennen jokaista valintaa. Virhe kuuluu
7
+ * ryhmälle eikä yksittäiselle valinnalle: "valitse yksi" koskee
8
+ * kaikkia. Virhetilassa jokaisen valinnan reuna on --danger, ja viesti
9
+ * on ikonin kanssa kuten TextFieldissä.
10
+ *
11
+ * Yksittäinenkin pakollinen valinta ("hyväksy ehdot") tehdään ryhmänä,
12
+ * jotta virheelle on paikka.
13
+ */
14
+ export type ChoiceGroupProps = {
15
+ legend: string;
16
+ /** Ohjeteksti valintojen alla. */
17
+ hint?: string;
18
+ /** Virheviesti. Kun annettu, ryhmä on virhetilassa. */
19
+ error?: string;
20
+ children: ReactNode;
21
+ className?: string;
22
+ };
23
+ export default function ChoiceGroup({ legend, hint, error, children, className }: ChoiceGroupProps): import("react/jsx-runtime").JSX.Element;
package/ChoiceGroup.js ADDED
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import Icon from './Icon';
4
+ export default function ChoiceGroup({ legend, hint, error, children, className }) {
5
+ const id = useId();
6
+ const ohjeId = hint ? `${id}-ohje` : undefined;
7
+ const virheId = error ? `${id}-virhe` : undefined;
8
+ const kuvaus = [ohjeId, virheId].filter(Boolean).join(' ') || undefined;
9
+ return (_jsxs("fieldset", { className: ['choice-group', error && 'choice-group--invalid', className].filter(Boolean).join(' '), "aria-describedby": kuvaus, children: [_jsx("legend", { className: "choice-group__legend", children: legend }), children, hint ? (_jsx("p", { id: ohjeId, className: "field__hint", children: hint })) : null, error ? (_jsxs("p", { id: virheId, className: "field__error", children: [_jsx(Icon, { name: "alert" }), error] })) : null] }));
10
+ }
package/Icon.d.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  * päin, `arrow-up-right` ulos. Ero on lukijalle sama kuin
26
26
  * target="_blank" ruudunlukijalle.
27
27
  */
28
- export type IconName = 'arrow-right' | 'arrow-up-right' | 'plus' | 'minus' | 'close' | 'menu' | 'alert';
28
+ export type IconName = 'arrow-right' | 'arrow-up-right' | 'plus' | 'minus' | 'close' | 'menu' | 'alert' | 'chevron-down' | 'check';
29
29
  export type IconSize = 's' | 'm' | 'l';
30
30
  export type IconProps = {
31
31
  name: IconName;
package/Icon.js CHANGED
@@ -12,6 +12,10 @@ const PATHS = {
12
12
  vinot sivut eivät osu puolikkaan pikselin ruudukolle. Piste on
13
13
  lyhyt viiva, koska täyttöä ei ole. */
14
14
  alert: ['M2.5 2.5 H14.5 V14.5 H2.5 Z', 'M8.5 5 V10', 'M8.5 11.5 V12.5'],
15
+ /* Selectin nuoli ja valitun vaihtoehdon merkki. 45 asteen viivat
16
+ puolikkaan pikselin ruudukolla, kuten nuolissa. */
17
+ 'chevron-down': ['M4.5 6.5 L8.5 10.5 L12.5 6.5'],
18
+ check: ['M3.5 8.5 L6.5 11.5 L13.5 4.5'],
15
19
  };
16
20
  export default function Icon({ name, size = 'm', label, className }) {
17
21
  return (_jsx("svg", { className: ['icon', `icon--${size}`, className].filter(Boolean).join(' '), viewBox: "0 0 16 16", role: label ? 'img' : undefined, "aria-label": label, "aria-hidden": label ? undefined : true, focusable: "false", children: PATHS[name].map((d) => (_jsx("path", { d: d }, d))) }));
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pokela/components
2
2
 
3
- React-komponentit: **Pokela Design System**, versio 2.9.0.
3
+ React-komponentit: **Pokela Design System**, versio 2.11.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
@@ -26,6 +26,11 @@ tyylit saa myös suoraan: `@pokela/components/styles.css`.
26
26
  - `PageHeader`
27
27
  - `Section`
28
28
  - `TextField`
29
+ - `Checkbox`
30
+ - `Radio`
31
+ - `Switch`
32
+ - `ChoiceGroup`
33
+ - `Select`
29
34
  - `Grid`
30
35
  - `Reveal`
31
36
  - `ThemeScript`
@@ -62,6 +67,8 @@ import Link from 'next/link';
62
67
  - `styles/exploded-view.css`
63
68
  - `styles/patterns.css`
64
69
  - `styles/text-field.css`
70
+ - `styles/choice.css`
71
+ - `styles/select.css`
65
72
 
66
73
  Luokat ovat globaaleja, eivät CSS-moduuleja. Se on tarkoituksellista: sama
67
74
  luokka on myös Figmassa komponentin nimenä, ja hajautettu nimi katkaisisi
package/Radio.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { type InputHTMLAttributes } from 'react';
2
+ /**
3
+ * Radiopainike: yksi vaihtoehto ryhmästä, josta valitaan yksi.
4
+ *
5
+ * Sovelluskomponentti (päätös 22). Ympyrä on systeemin ainoa pyöreä
6
+ * muoto (päätös 24): ilman sitä radio ja valintaruutu näyttäisivät
7
+ * samalta, eikä käyttäjä näkisi, valitaanko yksi vai monta.
8
+ *
9
+ * Käytä aina ChoiceGroupin sisällä samalla `name`-arvolla.
10
+ */
11
+ export type RadioProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'className'> & {
12
+ label: string;
13
+ /** Ohjeteksti labelin alla. */
14
+ hint?: string;
15
+ className?: string;
16
+ };
17
+ export default function Radio(props: RadioProps): import("react/jsx-runtime").JSX.Element;
package/Radio.js ADDED
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ export default function Radio(props) {
4
+ const { label, hint, className, id: annettuId, ...input } = props;
5
+ const omaId = useId();
6
+ const id = annettuId ?? omaId;
7
+ const ohjeId = hint ? `${id}-ohje` : undefined;
8
+ return (_jsxs("div", { className: ['choice', 'choice--radio', input.disabled && 'choice--disabled', className]
9
+ .filter(Boolean)
10
+ .join(' '), children: [_jsxs("span", { className: "choice__control", children: [_jsx("input", { ...input, id: id, type: "radio", className: "choice__input", "aria-describedby": ohjeId }), _jsx("span", { className: "choice__mark", "aria-hidden": "true" })] }), _jsx("label", { className: "choice__label", htmlFor: id, children: label }), hint ? (_jsx("p", { id: ohjeId, className: "choice__hint", children: hint })) : null] }));
11
+ }
package/Select.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Valintalista: label, kenttä, oma lista, ohjeteksti ja virhe.
3
+ *
4
+ * Sovelluskomponentti (päätös 22). Suljettu kenttä on TextFieldin
5
+ * kenttä; lista on systeemin oma (päätös 25), koska Vellu halusi
6
+ * listankin hiusviivakieleen. Natiivi <select> antaisi saavutettavuuden
7
+ * ilmaiseksi, joten tämä toteuttaa sen itse WAI-ARIA APG:n
8
+ * "select-only combobox" -mallin mukaan:
9
+ *
10
+ * kenttä role="combobox", aria-expanded, aria-controls,
11
+ * aria-activedescendant. Fokus pysyy kentässä koko ajan;
12
+ * lista näyttää aktiivisen vaihtoehdon.
13
+ * lista role="listbox", vaihtoehdot role="option" ja
14
+ * aria-selected.
15
+ *
16
+ * Näppäimet: suljettuna Enter, välilyönti, nuolet, Home ja End
17
+ * avaavat, kirjain hyppää vaihtoehtoon. Auki nuolet, Home, End,
18
+ * PageUp ja PageDown liikkuvat, Enter ja välilyönti valitsevat, Esc
19
+ * sulkee muuttamatta, Tab valitsee ja siirtyy eteenpäin. Kirjoittaminen
20
+ * hakee alkukirjaimilla. Klikkaus listan ulkopuolelle sulkee.
21
+ *
22
+ * Lomakkeeseen arvo menee piilokentästä, kun `name` on annettu.
23
+ */
24
+ export type SelectOption = {
25
+ value: string;
26
+ label: string;
27
+ disabled?: boolean;
28
+ };
29
+ export type SelectProps = {
30
+ label: string;
31
+ options: SelectOption[];
32
+ /** Ohjattu arvo. Käytä `onChange`:n kanssa. */
33
+ value?: string;
34
+ /** Alkuarvo, kun arvoa ei ohjata. */
35
+ defaultValue?: string;
36
+ onChange?: (value: string) => void;
37
+ /** Teksti kun mitään ei ole valittu, esim. "Valitse rooli". */
38
+ placeholder?: string;
39
+ /** Ohjeteksti kentän alla. */
40
+ hint?: string;
41
+ /** Virheviesti. Kun annettu, kenttä on virhetilassa (aria-invalid). */
42
+ error?: string;
43
+ /** Valinnaisen kentän merkintä labelin perässä, esim. "(valinnainen)". */
44
+ optional?: string;
45
+ disabled?: boolean;
46
+ required?: boolean;
47
+ /** Lomakekentän nimi. Arvo lähtee lomakkeen mukana piilokentästä. */
48
+ name?: string;
49
+ id?: string;
50
+ className?: string;
51
+ };
52
+ export default function Select(props: SelectProps): import("react/jsx-runtime").JSX.Element;
package/Select.js ADDED
@@ -0,0 +1,218 @@
1
+ 'use client';
2
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
4
+ import Icon from './Icon';
5
+ /** Montako riviä PageUp ja PageDown hyppäävät: listan näkyvä korkeus. */
6
+ const SIVU = 6;
7
+ /** Kuinka kauan kirjainhaku muistaa edelliset kirjaimet. */
8
+ const HAKU_MS = 500;
9
+ export default function Select(props) {
10
+ const { label, options, value: ohjattu, defaultValue, onChange, placeholder, hint, error, optional, disabled, required, name, id: annettuId, className, } = props;
11
+ const omaId = useId();
12
+ const id = annettuId ?? omaId;
13
+ const labelId = `${id}-label`;
14
+ const listaId = `${id}-lista`;
15
+ const ohjeId = hint ? `${id}-ohje` : undefined;
16
+ const virheId = error ? `${id}-virhe` : undefined;
17
+ const kuvaus = [ohjeId, virheId].filter(Boolean).join(' ') || undefined;
18
+ const optioId = (i) => `${id}-optio-${i}`;
19
+ const [oma, setOma] = useState(defaultValue);
20
+ const arvo = ohjattu !== undefined ? ohjattu : oma;
21
+ const valittu = options.findIndex((o) => o.value === arvo);
22
+ const [auki, setAuki] = useState(false);
23
+ const [aktiivinen, setAktiivinen] = useState(-1);
24
+ const [ylos, setYlos] = useState(false);
25
+ const kentta = useRef(null);
26
+ const lista = useRef(null);
27
+ const juuri = useRef(null);
28
+ const haku = useRef({ teksti: '', aika: 0 });
29
+ const kaytossa = useCallback((i) => i >= 0 && i < options.length && !options[i].disabled, [options]);
30
+ /** Seuraava käytössä oleva vaihtoehto suuntaan `askel`, tai sama jos reunassa. */
31
+ const siirra = useCallback((alku, askel) => {
32
+ let i = alku;
33
+ for (let n = 0; n < options.length; n++) {
34
+ const seuraava = i + askel;
35
+ if (seuraava < 0 || seuraava >= options.length)
36
+ break;
37
+ i = seuraava;
38
+ if (kaytossa(i))
39
+ return i;
40
+ }
41
+ return kaytossa(alku) ? alku : -1;
42
+ }, [options, kaytossa]);
43
+ const ensimmainen = useCallback(() => siirra(-1, 1), [siirra]);
44
+ const viimeinen = useCallback(() => siirra(options.length, -1), [siirra, options.length]);
45
+ const valitse = (i) => {
46
+ if (!kaytossa(i))
47
+ return;
48
+ const uusi = options[i].value;
49
+ if (ohjattu === undefined)
50
+ setOma(uusi);
51
+ if (uusi !== arvo)
52
+ onChange?.(uusi);
53
+ };
54
+ const avaa = (aktiivi) => {
55
+ if (disabled)
56
+ return;
57
+ setAuki(true);
58
+ setAktiivinen(aktiivi >= 0 ? aktiivi : ensimmainen());
59
+ };
60
+ const sulje = () => {
61
+ setAuki(false);
62
+ setAktiivinen(-1);
63
+ };
64
+ /* Aktiivinen vaihtoehto pysyy näkyvissä pitkässä listassa. */
65
+ useEffect(() => {
66
+ if (!auki || aktiivinen < 0)
67
+ return;
68
+ document.getElementById(optioId(aktiivinen))?.scrollIntoView({ block: 'nearest' });
69
+ }, [auki, aktiivinen]);
70
+ /* Lista aukeaa ylös, jos alla ei ole tilaa mutta yllä on. */
71
+ useEffect(() => {
72
+ if (!auki || !kentta.current || !lista.current)
73
+ return;
74
+ const k = kentta.current.getBoundingClientRect();
75
+ const korkeus = lista.current.offsetHeight;
76
+ const alla = window.innerHeight - k.bottom;
77
+ setYlos(alla < korkeus && k.top > alla);
78
+ }, [auki]);
79
+ /* Klikkaus tai kosketus komponentin ulkopuolella sulkee. */
80
+ useEffect(() => {
81
+ if (!auki)
82
+ return;
83
+ const ulkona = (e) => {
84
+ if (!juuri.current?.contains(e.target))
85
+ sulje();
86
+ };
87
+ document.addEventListener('pointerdown', ulkona);
88
+ return () => document.removeEventListener('pointerdown', ulkona);
89
+ }, [auki]);
90
+ /** Kirjainhaku: kirjaimet kertyvät hetken, ja sama kirjain kiertää vaihtoehtoja. */
91
+ const hae = (merkki) => {
92
+ const nyt = Date.now();
93
+ const h = haku.current;
94
+ h.teksti = nyt - h.aika > HAKU_MS ? merkki : h.teksti + merkki;
95
+ h.aika = nyt;
96
+ const sama = h.teksti.split('').every((c) => c === h.teksti[0]);
97
+ const etsittava = (sama ? h.teksti[0] : h.teksti).toLocaleLowerCase('fi');
98
+ const lahto = auki ? aktiivinen : valittu;
99
+ const alku = sama || h.teksti.length === 1 ? lahto + 1 : Math.max(lahto, 0);
100
+ for (let n = 0; n < options.length; n++) {
101
+ const i = (alku + n) % options.length;
102
+ if (kaytossa(i) && options[i].label.toLocaleLowerCase('fi').startsWith(etsittava))
103
+ return i;
104
+ }
105
+ return -1;
106
+ };
107
+ const nappain = (e) => {
108
+ if (disabled)
109
+ return;
110
+ const { key, altKey } = e;
111
+ if (!auki) {
112
+ if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ') {
113
+ e.preventDefault();
114
+ avaa(valittu >= 0 ? valittu : key === 'ArrowUp' ? viimeinen() : ensimmainen());
115
+ }
116
+ else if (key === 'Home') {
117
+ e.preventDefault();
118
+ avaa(ensimmainen());
119
+ }
120
+ else if (key === 'End') {
121
+ e.preventDefault();
122
+ avaa(viimeinen());
123
+ }
124
+ else if (key.length === 1 && !e.ctrlKey && !e.metaKey && !altKey) {
125
+ const i = hae(key);
126
+ if (i >= 0)
127
+ avaa(i);
128
+ }
129
+ return;
130
+ }
131
+ switch (key) {
132
+ case 'ArrowDown':
133
+ e.preventDefault();
134
+ setAktiivinen((a) => siirra(a, 1));
135
+ break;
136
+ case 'ArrowUp':
137
+ e.preventDefault();
138
+ if (altKey) {
139
+ valitse(aktiivinen);
140
+ sulje();
141
+ }
142
+ else
143
+ setAktiivinen((a) => siirra(a, -1));
144
+ break;
145
+ case 'Home':
146
+ e.preventDefault();
147
+ setAktiivinen(ensimmainen());
148
+ break;
149
+ case 'End':
150
+ e.preventDefault();
151
+ setAktiivinen(viimeinen());
152
+ break;
153
+ case 'PageDown':
154
+ e.preventDefault();
155
+ setAktiivinen((a) => {
156
+ let i = a;
157
+ for (let n = 0; n < SIVU; n++)
158
+ i = siirra(i, 1);
159
+ return i;
160
+ });
161
+ break;
162
+ case 'PageUp':
163
+ e.preventDefault();
164
+ setAktiivinen((a) => {
165
+ let i = a;
166
+ for (let n = 0; n < SIVU; n++)
167
+ i = siirra(i, -1);
168
+ return i;
169
+ });
170
+ break;
171
+ case 'Enter':
172
+ case ' ':
173
+ e.preventDefault();
174
+ if (key === ' ' && haku.current.teksti && Date.now() - haku.current.aika < HAKU_MS) {
175
+ /* Välilyönti kesken haun on osa hakua ("Uusi Seelanti"). */
176
+ const i = hae(' ');
177
+ if (i >= 0)
178
+ setAktiivinen(i);
179
+ break;
180
+ }
181
+ valitse(aktiivinen);
182
+ sulje();
183
+ break;
184
+ case 'Escape':
185
+ e.preventDefault();
186
+ sulje();
187
+ break;
188
+ case 'Tab':
189
+ /* Tab hyväksyy aktiivisen ja antaa fokuksen siirtyä. */
190
+ valitse(aktiivinen);
191
+ sulje();
192
+ break;
193
+ default:
194
+ if (key.length === 1 && !e.ctrlKey && !e.metaKey && !altKey) {
195
+ const i = hae(key);
196
+ if (i >= 0)
197
+ setAktiivinen(i);
198
+ }
199
+ }
200
+ };
201
+ const naytettava = valittu >= 0 ? options[valittu].label : placeholder;
202
+ return (_jsxs("div", { ref: juuri, className: ['field', 'select', error && 'field--invalid', disabled && 'field--disabled', className]
203
+ .filter(Boolean)
204
+ .join(' '), children: [_jsxs("span", { id: labelId, className: "field__label", onClick: () => kentta.current?.focus(), children: [label, optional ? _jsxs("span", { className: "field__optional", children: [" ", optional] }) : null] }), _jsxs("div", { className: "select__wrap", children: [_jsxs("div", { ref: kentta, id: id, role: "combobox", tabIndex: disabled ? -1 : 0, className: ['field__control', 'select__control', valittu < 0 && 'select__control--placeholder']
205
+ .filter(Boolean)
206
+ .join(' '), "aria-labelledby": labelId, "aria-controls": listaId, "aria-expanded": auki, "aria-haspopup": "listbox", "aria-activedescendant": auki && aktiivinen >= 0 ? optioId(aktiivinen) : undefined, "aria-describedby": kuvaus, "aria-invalid": error ? true : undefined, "aria-required": required || undefined, "aria-disabled": disabled || undefined, onClick: () => (auki ? sulje() : avaa(valittu)), onKeyDown: nappain, onBlur: (e) => {
207
+ if (!juuri.current?.contains(e.relatedTarget))
208
+ sulje();
209
+ }, children: [_jsx("span", { className: "select__value", children: naytettava }), _jsx(Icon, { name: "chevron-down", className: "select__chevron" })] }), _jsx("ul", { ref: lista, id: listaId, role: "listbox", "aria-labelledby": labelId, tabIndex: -1, hidden: !auki, className: ['select__list', ylos && 'select__list--up'].filter(Boolean).join(' '), children: options.map((o, i) => (_jsxs("li", { id: optioId(i), role: "option", "aria-selected": i === valittu, "aria-disabled": o.disabled || undefined, className: ['select__option', i === aktiivinen && 'is-active'].filter(Boolean).join(' '),
210
+ /* Fokus pysyy kentässä: hiiren painallus ei saa viedä sitä. */
211
+ onPointerDown: (e) => e.preventDefault(), onPointerMove: () => kaytossa(i) && i !== aktiivinen && setAktiivinen(i), onClick: () => {
212
+ if (!kaytossa(i))
213
+ return;
214
+ valitse(i);
215
+ sulje();
216
+ kentta.current?.focus();
217
+ }, children: [_jsx("span", { children: o.label }), i === valittu ? _jsx(Icon, { name: "check", className: "select__check" }) : null] }, o.value))) })] }), name ? _jsx("input", { type: "hidden", name: name, value: arvo ?? '' }) : null, hint ? (_jsx("p", { id: ohjeId, className: "field__hint", children: hint })) : null, error ? (_jsxs("p", { id: virheId, className: "field__error", children: [_jsx(Icon, { name: "alert" }), error] })) : null] }));
218
+ }
package/Switch.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { type InputHTMLAttributes } from 'react';
2
+ /**
3
+ * Kytkin: asetus, joka tulee voimaan heti.
4
+ *
5
+ * Sovelluskomponentti (päätös 22). Valintaruutu tallennetaan napilla,
6
+ * kytkin ei. Siksi kytkin on asetuslistan rivi: label vasemmalla,
7
+ * kytkin oikeassa reunassa. Kisko ja nuppi ovat neliöt kuten muu
8
+ * systeemi; päällä kisko täyttyy --inkillä.
9
+ *
10
+ * Alla on <input type="checkbox" role="switch">, joten ruudunlukija
11
+ * sanoo "päällä" tai "pois".
12
+ */
13
+ export type SwitchProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'className' | 'role'> & {
14
+ label: string;
15
+ /** Ohjeteksti labelin alla. */
16
+ hint?: string;
17
+ className?: string;
18
+ };
19
+ export default function Switch(props: SwitchProps): import("react/jsx-runtime").JSX.Element;
package/Switch.js ADDED
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ export default function Switch(props) {
4
+ const { label, hint, className, id: annettuId, ...input } = props;
5
+ const omaId = useId();
6
+ const id = annettuId ?? omaId;
7
+ const ohjeId = hint ? `${id}-ohje` : undefined;
8
+ return (_jsxs("div", { className: ['choice', 'choice--switch', input.disabled && 'choice--disabled', className]
9
+ .filter(Boolean)
10
+ .join(' '), children: [_jsxs("span", { className: "choice__control", children: [_jsx("input", { ...input, id: id, type: "checkbox", role: "switch", className: "choice__input", "aria-describedby": ohjeId }), _jsx("span", { className: "choice__mark", "aria-hidden": "true" })] }), _jsx("label", { className: "choice__label", htmlFor: id, children: label }), hint ? (_jsx("p", { id: ohjeId, className: "choice__hint", children: hint })) : null] }));
11
+ }
@@ -11,6 +11,11 @@ Use these instead of building an equivalent.
11
11
  - `PageHeader`
12
12
  - `Section`
13
13
  - `TextField`
14
+ - `Checkbox`
15
+ - `Radio`
16
+ - `Switch`
17
+ - `ChoiceGroup`
18
+ - `Select`
14
19
  - `Grid`, `Col`
15
20
  - `Reveal`
16
21
  - `ThemeScript`, `THEME_STORAGE_KEY`
@@ -82,6 +87,47 @@ classes.
82
87
  ```jsx
83
88
  <TextField label="Email" type="email" error="The address is missing a domain." />
84
89
  <TextField label="Message" optional="(optional)" multiline hint="Up to 500 characters." />
90
+ ```
91
+
92
+ `Checkbox`, `Radio` and `Switch` are the choices; put them inside a
93
+ `ChoiceGroup`, which gives the group its title (`legend`), `hint` and `error`.
94
+ Use `Checkbox` for options saved with a button, `Radio` (same `name`) when
95
+ exactly one option is picked, and `Switch` for a setting that takes effect at
96
+ once, never in a form with a save button. An error belongs to the group, not
97
+ to a single choice; a single required choice ("accept the terms") is still a
98
+ `ChoiceGroup`.
99
+
100
+ ```jsx
101
+ <ChoiceGroup legend="Visibility" error="Pick one.">
102
+ <Radio name="visibility" value="public" label="Public" hint="Anyone with the link." />
103
+ <Radio name="visibility" value="team" label="Team only" />
104
+ </ChoiceGroup>
105
+ <ChoiceGroup legend="Notifications">
106
+ <Switch label="Email notifications" defaultChecked />
107
+ </ChoiceGroup>
108
+ ```
109
+
110
+ `Select` picks one value from a list: same field as `TextField` when closed,
111
+ the system's own list when open. Use it when there are more than about five
112
+ options or the list would crowd the page; with fewer, use `Radio` in a
113
+ `ChoiceGroup` so every option is visible. Pass `options` as
114
+ `{ value, label, disabled? }`, give a `placeholder` when nothing is selected
115
+ yet, and pass `name` to send the value with a form. It follows the WAI-ARIA
116
+ select-only combobox: arrow keys, Home and End, type-ahead and Escape all
117
+ work, so do not build a dropdown from a button and a menu.
118
+
119
+ ```jsx
120
+ <Select
121
+ label="Role"
122
+ placeholder="Pick a role"
123
+ options={[
124
+ { value: 'developer', label: 'Developer' },
125
+ { value: 'designer', label: 'Designer' },
126
+ ]}
127
+ error="Pick a role to continue."
128
+ />
129
+ ```
130
+
85
131
  ## Patterns
86
132
 
87
133
  `PageHeader` and `Section` are patterns: they add no look of their own, only
@@ -30,5 +30,5 @@ else. Never set body text in the display face.
30
30
  <!-- generated:make-sarakkeet -->`Grid` is 12 columns: 4 on mobile, 8 from 600px, 12 from 900px.<!-- /generated --> Place
31
31
  things in columns, never in pixels. `Col` takes the span per breakpoint.
32
32
 
33
- **No icon libraries and no emoji.** Use `Icon`. <!-- generated:make-ikonit -->It draws its own 7 marks<!-- /generated --> on
33
+ **No icon libraries and no emoji.** Use `Icon`. <!-- generated:make-ikonit -->It draws its own 9 marks<!-- /generated --> on
34
34
  a 16×16 grid with the same hairline as every border.
@@ -9,13 +9,14 @@ inversion — `--invert-surface` with `--invert-ink` — or the quiet
9
9
  coloured button. Colour on the page comes only from photographs.
10
10
 
11
11
  The one exception is `--danger`, a state colour for errors only. Do not use it
12
- for emphasis, buttons or decoration. `TextField` already applies it, always
12
+ for emphasis, buttons or decoration. `TextField`, `ChoiceGroup` and `Select` already apply it, always
13
13
  with the `alert` icon and a message, so the meaning never depends on colour.
14
14
 
15
15
  **No shadows.** There are no shadow tokens. Separation is a 1px line
16
16
  (`--hairline`), never elevation.
17
17
 
18
- **No rounded corners.** `--radius` is `0`. Everything is square.
18
+ **No rounded corners.** `--radius` is `0`. Everything is square. The one
19
+ exception is `Radio`, which is a circle so it never reads as a checkbox.
19
20
 
20
21
  <!-- generated:make-valit -->**No spacing outside the scale.** 16 steps, each named by its value:<!-- /generated -->
21
22
  `--space-24` is 24px. If a value is missing, use the nearest step rather than
package/index.d.ts CHANGED
@@ -14,6 +14,16 @@ export { default as Section } from './Section.js';
14
14
  export type { SectionLayout } from './Section.js';
15
15
  export { default as TextField } from './TextField.js';
16
16
  export type { TextFieldProps } from './TextField.js';
17
+ export { default as Checkbox } from './Checkbox.js';
18
+ export type { CheckboxProps } from './Checkbox.js';
19
+ export { default as Radio } from './Radio.js';
20
+ export type { RadioProps } from './Radio.js';
21
+ export { default as Switch } from './Switch.js';
22
+ export type { SwitchProps } from './Switch.js';
23
+ export { default as ChoiceGroup } from './ChoiceGroup.js';
24
+ export type { ChoiceGroupProps } from './ChoiceGroup.js';
25
+ export { default as Select } from './Select.js';
26
+ export type { SelectOption, SelectProps } from './Select.js';
17
27
  export { Grid, Col } from './Grid.js';
18
28
  export { default as Reveal } from './Reveal.js';
19
29
  export { default as ThemeScript, THEME_STORAGE_KEY } from './ThemeScript.js';
package/index.js CHANGED
@@ -8,6 +8,11 @@ export { default as ExplodedView } from './ExplodedView.js';
8
8
  export { default as PageHeader } from './PageHeader.js';
9
9
  export { default as Section } from './Section.js';
10
10
  export { default as TextField } from './TextField.js';
11
+ export { default as Checkbox } from './Checkbox.js';
12
+ export { default as Radio } from './Radio.js';
13
+ export { default as Switch } from './Switch.js';
14
+ export { default as ChoiceGroup } from './ChoiceGroup.js';
15
+ export { default as Select } from './Select.js';
11
16
  export { Grid, Col } from './Grid.js';
12
17
  export { default as Reveal } from './Reveal.js';
13
18
  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.9.0",
3
+ "version": "2.11.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.9.0"
30
+ "@pokela/tokens": "^2.11.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": ">=18"
@@ -0,0 +1,195 @@
1
+ /* Valinnat: Checkbox, Radio, Switch ja ChoiceGroup.
2
+ ==================================================================
3
+ Sovelluskomponentteja (päätös 22), sivusto ei käytä näitä. Kieli on
4
+ .btn- ja .field-perheen: hiusviiva --line-strong, ei pyöristystä
5
+ (paitsi radio, päätös 24), valittu = täytetty --ink.
6
+
7
+ Oikea <input> on merkin päällä näkymättömänä (opacity 0), joten
8
+ klikkaus, näppäimistö ja lomake toimivat natiivisti, ja merkin tila
9
+ luetaan inputin tilasta: `.choice__input:checked + .choice__mark`.
10
+
11
+ Hover paksuntaa reunan sisäänpäin piirretyllä viivalla eikä
12
+ border-widthillä: kytkimen nuppi on sijoitettu reunan sisään, eikä
13
+ se saa nytkähtää. */
14
+
15
+ .choice {
16
+ display: grid;
17
+ grid-template-columns: auto 1fr;
18
+ column-gap: var(--space-12);
19
+ align-items: start;
20
+ min-height: var(--tap-min);
21
+ /* Yksirivinen valinta on --tap-min korkea, teksti keskellä. */
22
+ padding-block: calc((var(--tap-min) - var(--lh-body) * var(--text-body)) / 2);
23
+ }
24
+
25
+ .choice__control {
26
+ position: relative;
27
+ display: block;
28
+ width: var(--space-20);
29
+ height: var(--space-20);
30
+ /* Merkki ensimmäisen rivin keskelle. */
31
+ margin-top: calc((var(--lh-body) * var(--text-body) - var(--space-20)) / 2);
32
+ }
33
+
34
+ .choice__input {
35
+ position: absolute;
36
+ inset: 0;
37
+ width: 100%;
38
+ height: 100%;
39
+ margin: 0;
40
+ opacity: 0;
41
+ cursor: pointer;
42
+ }
43
+
44
+ .choice__mark {
45
+ position: absolute;
46
+ inset: 0;
47
+ border: var(--hairline) solid var(--line-strong);
48
+ border-radius: var(--radius);
49
+ background: var(--paper);
50
+ pointer-events: none;
51
+ transition:
52
+ background var(--dur-fast) var(--ease-standard),
53
+ border-color var(--dur-fast) var(--ease-standard),
54
+ box-shadow var(--dur-fast) var(--ease-standard);
55
+ }
56
+
57
+ .choice__mark svg { position: absolute; inset: 0; width: 100%; height: 100%; }
58
+ .choice__mark path {
59
+ fill: none;
60
+ stroke: var(--paper);
61
+ stroke-width: var(--hairline-strong);
62
+ stroke-linecap: butt;
63
+ stroke-linejoin: miter;
64
+ opacity: 0;
65
+ }
66
+
67
+ .choice__label {
68
+ font-size: var(--text-body);
69
+ line-height: var(--lh-body);
70
+ color: var(--ink);
71
+ cursor: pointer;
72
+ }
73
+
74
+ .choice__hint {
75
+ grid-column: 2;
76
+ margin: 0;
77
+ font-size: var(--text-body-s);
78
+ line-height: var(--lh-body-s);
79
+ color: var(--ink-muted);
80
+ }
81
+
82
+ /* ---- tilat ------------------------------------------------------- */
83
+
84
+ .choice:hover .choice__input:not(:disabled) + .choice__mark {
85
+ box-shadow: inset 0 0 0 var(--hairline) var(--line-strong);
86
+ }
87
+
88
+ .choice__input:focus-visible + .choice__mark {
89
+ outline: var(--focus-width) solid var(--ink);
90
+ outline-offset: var(--focus-offset);
91
+ }
92
+
93
+ .choice__input:checked + .choice__mark,
94
+ .choice__input:indeterminate + .choice__mark {
95
+ background: var(--ink);
96
+ border-color: var(--ink);
97
+ }
98
+ .choice__input:checked:not(:indeterminate) + .choice__mark .choice__check,
99
+ .choice__input:indeterminate + .choice__mark .choice__dash { opacity: 1; }
100
+
101
+ /* ---- radio: ympyrä ja piste --------------------------------------- */
102
+
103
+ .choice--radio .choice__mark { border-radius: 50%; }
104
+ .choice--radio .choice__input:checked + .choice__mark {
105
+ background: var(--paper);
106
+ box-shadow: inset 0 0 0 var(--hairline) var(--ink);
107
+ }
108
+ .choice--radio .choice__input:checked + .choice__mark::after {
109
+ content: '';
110
+ position: absolute;
111
+ inset: calc(var(--space-4) + var(--hairline));
112
+ border-radius: 50%;
113
+ background: var(--ink);
114
+ }
115
+
116
+ /* ---- kytkin: kisko ja nuppi, label vasemmalla --------------------- */
117
+
118
+ .choice--switch { grid-template-columns: 1fr auto; column-gap: var(--space-24); }
119
+ .choice--switch .choice__control {
120
+ grid-column: 2;
121
+ grid-row: 1;
122
+ width: var(--space-40);
123
+ height: var(--space-24);
124
+ margin-top: calc((var(--lh-body) * var(--text-body) - var(--space-24)) / 2);
125
+ }
126
+ .choice--switch .choice__label { grid-column: 1; grid-row: 1; }
127
+ .choice--switch .choice__hint { grid-column: 1; }
128
+
129
+ /* Nuppi on 16 px ja istuu 4 px:n päässä kiskon ulkoreunasta. */
130
+ .choice--switch .choice__mark::after {
131
+ content: '';
132
+ position: absolute;
133
+ top: calc((var(--space-24) - var(--space-16)) / 2 - var(--hairline));
134
+ left: calc((var(--space-24) - var(--space-16)) / 2 - var(--hairline));
135
+ width: var(--space-16);
136
+ height: var(--space-16);
137
+ background: var(--ink-muted);
138
+ transition:
139
+ transform var(--dur-base) var(--ease-out),
140
+ background var(--dur-fast) var(--ease-standard);
141
+ }
142
+ .choice--switch .choice__input:checked + .choice__mark::after {
143
+ transform: translateX(var(--space-16));
144
+ background: var(--paper);
145
+ }
146
+
147
+ /* ---- disabled ---------------------------------------------------- */
148
+
149
+ .choice--disabled .choice__label,
150
+ .choice--disabled .choice__hint { color: var(--ink-faint); cursor: not-allowed; }
151
+ .choice__input:disabled { cursor: not-allowed; }
152
+ .choice__input:disabled + .choice__mark {
153
+ background: var(--paper-alt);
154
+ border-color: var(--ink-faint);
155
+ }
156
+ .choice__input:disabled:checked + .choice__mark,
157
+ .choice__input:disabled:indeterminate + .choice__mark {
158
+ background: var(--ink-faint);
159
+ border-color: var(--ink-faint);
160
+ }
161
+ .choice__input:disabled + .choice__mark .choice__check,
162
+ .choice__input:disabled + .choice__mark .choice__dash { stroke: var(--paper-alt); }
163
+ .choice--radio .choice__input:disabled:checked + .choice__mark { background: var(--paper-alt); box-shadow: none; }
164
+ .choice--radio .choice__input:disabled:checked + .choice__mark::after,
165
+ .choice--switch .choice__input:disabled + .choice__mark::after { background: var(--ink-faint); }
166
+ .choice--switch .choice__input:disabled:checked + .choice__mark::after { background: var(--paper-alt); }
167
+
168
+ /* ---- ryhmä ------------------------------------------------------- */
169
+
170
+ .choice-group {
171
+ display: grid;
172
+ margin: 0;
173
+ padding: 0;
174
+ border: 0;
175
+ min-width: 0;
176
+ }
177
+
178
+ /* Sama kuin .field__label: ryhmän otsikko on lomakkeen label. */
179
+ .choice-group__legend {
180
+ padding: 0;
181
+ margin-bottom: var(--space-4);
182
+ font-size: var(--text-body-s);
183
+ line-height: var(--lh-body-s);
184
+ font-weight: var(--weight-500);
185
+ color: var(--ink);
186
+ }
187
+
188
+ .choice-group > .field__hint,
189
+ .choice-group > .field__error { margin-top: var(--space-4); }
190
+
191
+ /* Virhe: jokaisen valinnan reuna --danger ja paksumpi, kuten kentässä. */
192
+ .choice-group--invalid .choice__input:not(:checked) + .choice__mark {
193
+ border-color: var(--danger);
194
+ box-shadow: inset 0 0 0 var(--hairline) var(--danger);
195
+ }
@@ -0,0 +1,101 @@
1
+ /* Valintalista (Select). Sovelluskomponentti, sivusto ei käytä tätä.
2
+ ==================================================================
3
+ Suljettu kenttä on .field__control (text-field.css): 48 px,
4
+ hiusviiva, hover paksuntaa alaviivan, virhe ja disabled samoin.
5
+ Tässä on vain se, mikä selectillä on omaa: nuoli, haalea
6
+ placeholder ja oma lista (päätös 25).
7
+
8
+ Lista on samaa kieltä: musteviiva, rivi --tap-min, rivien välissä
9
+ hiusviiva. Aktiivinen rivi (hiiri tai nuolinäppäin) saa pinnan
10
+ --paper-alt, valittu rivi painon 500 ja merkin. */
11
+
12
+ .select__wrap { position: relative; }
13
+
14
+ .select__control {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: var(--space-12);
18
+ cursor: pointer;
19
+ user-select: none;
20
+ /* Nuoli on rivin sisällä, joten oikea reuna on sama kuin vasen. */
21
+ }
22
+ .select__value {
23
+ flex: 1;
24
+ min-width: 0;
25
+ overflow: hidden;
26
+ white-space: nowrap;
27
+ text-overflow: ellipsis;
28
+ }
29
+ .select__control--placeholder .select__value { color: var(--ink-faint); }
30
+
31
+ .select__chevron { color: var(--ink); }
32
+ @media (prefers-reduced-motion: no-preference) {
33
+ .select__chevron { transition: transform var(--dur-fast) var(--ease-standard); }
34
+ }
35
+ .select__control[aria-expanded='true'] .select__chevron { transform: rotate(180deg); }
36
+
37
+ /* Auki kenttä on musteella, kuten fokus: lista kuuluu siihen. */
38
+ .select__control[aria-expanded='true'] { border-color: var(--ink); }
39
+ .field--invalid .select__control[aria-expanded='true'] { border-color: var(--danger); }
40
+ .select__control:focus-visible {
41
+ outline: var(--focus-width) solid var(--ink);
42
+ outline-offset: var(--focus-offset);
43
+ }
44
+
45
+ .field--disabled .select__control { cursor: not-allowed; }
46
+ .field--disabled .select__value,
47
+ .field--disabled .select__chevron { color: var(--ink-faint); }
48
+ .field--disabled .select__control {
49
+ background: var(--paper-alt);
50
+ border-color: var(--line);
51
+ }
52
+ /* Kenttä on div, joten text-field.css:n :not(:disabled) ei estä
53
+ hoveria. Estetään se tässä. */
54
+ .field--disabled .select__control:hover { border-bottom-width: var(--hairline); }
55
+
56
+ /* ---- lista ------------------------------------------------------- */
57
+
58
+ .select__list {
59
+ position: absolute;
60
+ z-index: 10;
61
+ /* Väli jättää fokusrenkaalle tilaa: rengas on 4 px kentän ulkopuolella. */
62
+ top: calc(100% + var(--space-8));
63
+ left: 0;
64
+ right: 0;
65
+ margin: 0;
66
+ padding: 0;
67
+ list-style: none;
68
+ background: var(--paper);
69
+ border: var(--hairline) solid var(--ink);
70
+ /* Kuusi riviä näkyvissä, loput vierittyvät. */
71
+ max-height: calc(var(--tap-min) * 6);
72
+ overflow-y: auto;
73
+ overscroll-behavior: contain;
74
+ }
75
+ .select__list[hidden] { display: none; }
76
+ .select__list--up { top: auto; bottom: calc(100% + var(--space-8)); }
77
+
78
+ .select__option {
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: space-between;
82
+ gap: var(--space-12);
83
+ min-height: var(--tap-min);
84
+ padding: 0 var(--space-16);
85
+ font-size: var(--text-body);
86
+ line-height: var(--lh-body);
87
+ color: var(--ink);
88
+ cursor: pointer;
89
+ }
90
+ .select__option + .select__option { border-top: var(--hairline) solid var(--line); }
91
+ .select__option.is-active { background: var(--paper-alt); }
92
+ .select__option[aria-selected='true'] { font-weight: var(--weight-500); }
93
+ .select__option[aria-disabled='true'] { color: var(--ink-faint); cursor: not-allowed; }
94
+ .select__check { color: var(--ink); }
95
+
96
+ /* Korkea kontrasti (Windows): taustaväri katoaa, joten aktiivinen rivi
97
+ tarvitsee viivan. */
98
+ @media (forced-colors: active) {
99
+ .select__option.is-active { outline: var(--focus-width) solid Highlight; outline-offset: calc(var(--focus-width) * -1); }
100
+ .select__option[aria-selected='true'] { forced-color-adjust: none; }
101
+ }
package/styles.css CHANGED
@@ -9,3 +9,5 @@
9
9
  @import './styles/exploded-view.css';
10
10
  @import './styles/patterns.css';
11
11
  @import './styles/text-field.css';
12
+ @import './styles/choice.css';
13
+ @import './styles/select.css';