@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 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.10.0.
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
+ }
@@ -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
@@ -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 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.
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
+ ```
@@ -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 `ChoiceGroup` already apply 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
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.10.0",
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.10.0"
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
@@ -10,3 +10,4 @@
10
10
  @import './styles/patterns.css';
11
11
  @import './styles/text-field.css';
12
12
  @import './styles/choice.css';
13
+ @import './styles/select.css';