@pokela/components 2.10.0 → 2.12.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/Icon.d.ts +1 -1
- package/Icon.js +4 -0
- package/README.md +3 -1
- package/Select.d.ts +52 -0
- package/Select.js +218 -0
- package/guidelines/components.md +22 -0
- package/guidelines/styles.md +21 -1
- package/guidelines/tokens.md +1 -1
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/package.json +2 -2
- package/styles/base.css +33 -0
- package/styles/select.css +101 -0
- package/styles.css +1 -0
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.12.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
|
|
@@ -30,6 +30,7 @@ tyylit saa myös suoraan: `@pokela/components/styles.css`.
|
|
|
30
30
|
- `Radio`
|
|
31
31
|
- `Switch`
|
|
32
32
|
- `ChoiceGroup`
|
|
33
|
+
- `Select`
|
|
33
34
|
- `Grid`
|
|
34
35
|
- `Reveal`
|
|
35
36
|
- `ThemeScript`
|
|
@@ -67,6 +68,7 @@ import Link from 'next/link';
|
|
|
67
68
|
- `styles/patterns.css`
|
|
68
69
|
- `styles/text-field.css`
|
|
69
70
|
- `styles/choice.css`
|
|
71
|
+
- `styles/select.css`
|
|
70
72
|
|
|
71
73
|
Luokat ovat globaaleja, eivät CSS-moduuleja. Se on tarkoituksellista: sama
|
|
72
74
|
luokka on myös Figmassa komponentin nimenä, ja hajautettu nimi katkaisisi
|
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/guidelines/components.md
CHANGED
|
@@ -15,6 +15,7 @@ Use these instead of building an equivalent.
|
|
|
15
15
|
- `Radio`
|
|
16
16
|
- `Switch`
|
|
17
17
|
- `ChoiceGroup`
|
|
18
|
+
- `Select`
|
|
18
19
|
- `Grid`, `Col`
|
|
19
20
|
- `Reveal`
|
|
20
21
|
- `ThemeScript`, `THEME_STORAGE_KEY`
|
|
@@ -106,6 +107,27 @@ to a single choice; a single required choice ("accept the terms") is still a
|
|
|
106
107
|
</ChoiceGroup>
|
|
107
108
|
```
|
|
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
|
+
|
|
109
131
|
## Patterns
|
|
110
132
|
|
|
111
133
|
`PageHeader` and `Section` are patterns: they add no look of their own, only
|
package/guidelines/styles.md
CHANGED
|
@@ -30,5 +30,25 @@ 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.
|
|
35
|
+
|
|
36
|
+
## Buttons
|
|
37
|
+
|
|
38
|
+
A button is a class, not a component: `btn` plus `btn--primary`, `btn--ghost`
|
|
39
|
+
or `btn--text`, on a `<button>` or an `<a>`. Use one primary per view. The
|
|
40
|
+
default size needs no class. Use `btn--s` in dense places such as a toolbar
|
|
41
|
+
above a table, and `btn--l` only for the one main action on a page. Text
|
|
42
|
+
buttons have one size. Keep at least `--space-12` between small buttons: each
|
|
43
|
+
one still has a 44 px touch area.
|
|
44
|
+
|
|
45
|
+
An icon-only button is `btn--icon` with `btn--primary` or `btn--ghost`, never
|
|
46
|
+
`btn--text`. Always give it an `aria-label`, because nothing on screen names it.
|
|
47
|
+
|
|
48
|
+
```jsx
|
|
49
|
+
<button type="button" className="btn btn--ghost btn--s">Filter</button>
|
|
50
|
+
<button type="button" className="btn btn--primary btn--icon btn--s" aria-label="New project">
|
|
51
|
+
<Icon name="plus" />
|
|
52
|
+
</button>
|
|
53
|
+
<a href="/contact" className="btn btn--primary btn--l">Get in touch <Icon name="arrow-right" /></a>
|
|
54
|
+
```
|
package/guidelines/tokens.md
CHANGED
|
@@ -9,7 +9,7 @@ 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` and `
|
|
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
|
package/index.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export { default as Switch } from './Switch.js';
|
|
|
22
22
|
export type { SwitchProps } from './Switch.js';
|
|
23
23
|
export { default as ChoiceGroup } from './ChoiceGroup.js';
|
|
24
24
|
export type { ChoiceGroupProps } from './ChoiceGroup.js';
|
|
25
|
+
export { default as Select } from './Select.js';
|
|
26
|
+
export type { SelectOption, SelectProps } from './Select.js';
|
|
25
27
|
export { Grid, Col } from './Grid.js';
|
|
26
28
|
export { default as Reveal } from './Reveal.js';
|
|
27
29
|
export { default as ThemeScript, THEME_STORAGE_KEY } from './ThemeScript.js';
|
package/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export { default as Checkbox } from './Checkbox.js';
|
|
|
12
12
|
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
|
+
export { default as Select } from './Select.js';
|
|
15
16
|
export { Grid, Col } from './Grid.js';
|
|
16
17
|
export { default as Reveal } from './Reveal.js';
|
|
17
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.12.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.12.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"react": ">=18"
|
package/styles/base.css
CHANGED
|
@@ -168,6 +168,39 @@ a:hover { text-decoration: underline; text-underline-offset: var(--space-4); }
|
|
|
168
168
|
.btn--block { width: 100%; }
|
|
169
169
|
@media (min-width: 600px) { .btn--block { width: auto; } }
|
|
170
170
|
|
|
171
|
+
/* Koot (päätös 26). Oletus on m: 44 px, sama kuin --tap-min. s on
|
|
172
|
+
tiiviisiin näkymiin (työkalurivi), l sivun ainoaan päätoimintoon.
|
|
173
|
+
Kieli ei muutu koon mukana: viiva, paino ja käännös ovat samat.
|
|
174
|
+
Tekstinapilla ei ole kokoja, koska sillä ei ole pintaa. */
|
|
175
|
+
.btn--s {
|
|
176
|
+
position: relative;
|
|
177
|
+
min-height: var(--space-32);
|
|
178
|
+
padding: var(--space-8) var(--space-16);
|
|
179
|
+
}
|
|
180
|
+
/* Näkyvä 32 px, osuma-alue silti --tap-min: näkymätön laajennus ylös
|
|
181
|
+
ja alas. Siksi s-nappien väli on vähintään --space-12, muuten
|
|
182
|
+
alueet menevät päällekkäin. Laajennus lasketaan reunaviivan
|
|
183
|
+
sisäpuolelta (absoluuttinen asemointi alkaa padding-laatikosta),
|
|
184
|
+
joten viiva vähennetään. */
|
|
185
|
+
.btn--s::after {
|
|
186
|
+
content: '';
|
|
187
|
+
position: absolute;
|
|
188
|
+
inset: calc((var(--space-32) - var(--tap-min)) / 2 - var(--hairline)) calc(var(--hairline) * -1);
|
|
189
|
+
}
|
|
190
|
+
.btn--l {
|
|
191
|
+
min-height: var(--space-56);
|
|
192
|
+
padding: var(--space-16) var(--space-32);
|
|
193
|
+
font-size: var(--text-body);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* Pelkkä ikoni: neliö, jonka sivu on napin korkeus. Nimi tulee aina
|
|
197
|
+
aria-labelilla, koska tekstiä ei ole. Vain primary ja ghost. */
|
|
198
|
+
.btn--icon { flex: none; width: var(--tap-min); padding: 0; }
|
|
199
|
+
.btn--icon.btn--s { width: var(--space-32); }
|
|
200
|
+
.btn--icon.btn--s::after { inset: calc((var(--space-32) - var(--tap-min)) / 2 - var(--hairline)); }
|
|
201
|
+
.btn--icon.btn--l { width: var(--space-56); }
|
|
202
|
+
.btn--icon.btn--l .icon { width: var(--icon-l); height: var(--icon-l); }
|
|
203
|
+
|
|
171
204
|
/* Hover = käännös. Reunaviiva tulee border-colorista, ei varjosta. */
|
|
172
205
|
.btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
|
173
206
|
.btn--primary:hover { background: var(--paper); color: var(--ink); }
|
|
@@ -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