@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 +24 -0
- package/Checkbox.js +15 -0
- package/ChoiceGroup.d.ts +23 -0
- package/ChoiceGroup.js +10 -0
- package/Icon.d.ts +1 -1
- package/Icon.js +4 -0
- package/README.md +8 -1
- package/Radio.d.ts +17 -0
- package/Radio.js +11 -0
- package/Select.d.ts +52 -0
- package/Select.js +218 -0
- package/Switch.d.ts +19 -0
- package/Switch.js +11 -0
- package/guidelines/components.md +46 -0
- package/guidelines/styles.md +1 -1
- package/guidelines/tokens.md +3 -2
- package/index.d.ts +10 -0
- package/index.js +5 -0
- package/package.json +2 -2
- package/styles/choice.css +195 -0
- package/styles/select.css +101 -0
- package/styles.css +2 -0
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
|
+
}
|
package/ChoiceGroup.d.ts
ADDED
|
@@ -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.
|
|
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
|
+
}
|
package/guidelines/components.md
CHANGED
|
@@ -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
|
package/guidelines/styles.md
CHANGED
|
@@ -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
|
|
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.
|
package/guidelines/tokens.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|
+
}
|