@pokela/components 2.7.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Checkbox.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { type InputHTMLAttributes } from 'react';
2
+ /**
3
+ * Valintaruutu: ruutu, label ja ohjeteksti.
4
+ *
5
+ * Sovelluskomponentti (päätös 22), sivusto ei käytä tätä. Kieli on
6
+ * .btn-perheen: hiusviiva --line-strong, ei pyöristystä, valittu ruutu
7
+ * täytetään --inkillä. Rivi on vähintään --tap-min korkea.
8
+ *
9
+ * Oikea <input> on ruudun päällä näkymättömänä, joten näppäimistö,
10
+ * lomake ja ruudunlukija toimivat kuten natiivissa. `indeterminate`
11
+ * ei ole HTML-attribuutti, joten se asetetaan elementille.
12
+ *
13
+ * Virhettä ei ole tässä: se kuuluu ryhmälle (ChoiceGroup), jonka
14
+ * legend kertoo, mitä puuttuu.
15
+ */
16
+ export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'className'> & {
17
+ label: string;
18
+ /** Ohjeteksti labelin alla. */
19
+ hint?: string;
20
+ /** Osittain valittu, esim. "valitse kaikki", kun osa on valittu. */
21
+ indeterminate?: boolean;
22
+ className?: string;
23
+ };
24
+ export default function Checkbox(props: CheckboxProps): import("react/jsx-runtime").JSX.Element;
package/Checkbox.js ADDED
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef } from 'react';
4
+ export default function Checkbox(props) {
5
+ const { label, hint, indeterminate, className, id: annettuId, ...input } = props;
6
+ const omaId = useId();
7
+ const id = annettuId ?? omaId;
8
+ const ohjeId = hint ? `${id}-ohje` : undefined;
9
+ const ref = useRef(null);
10
+ useEffect(() => {
11
+ if (ref.current)
12
+ ref.current.indeterminate = Boolean(indeterminate);
13
+ }, [indeterminate]);
14
+ return (_jsxs("div", { className: ['choice', input.disabled && 'choice--disabled', className].filter(Boolean).join(' '), children: [_jsxs("span", { className: "choice__control", children: [_jsx("input", { ...input, ref: ref, id: id, type: "checkbox", className: "choice__input", "aria-describedby": ohjeId }), _jsx("span", { className: "choice__mark", "aria-hidden": "true", children: _jsxs("svg", { viewBox: "0 0 20 20", children: [_jsx("path", { className: "choice__check", d: "M5 10 L8.5 13.5 L15 6.5" }), _jsx("path", { className: "choice__dash", d: "M5 10 H15" })] }) })] }), _jsx("label", { className: "choice__label", htmlFor: id, children: label }), hint ? (_jsx("p", { id: ohjeId, className: "choice__hint", children: hint })) : null] }));
15
+ }
@@ -0,0 +1,23 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Valintojen ryhmä: otsikko (legend), valinnat, ohje ja virhe.
4
+ *
5
+ * Sovelluskomponentti (päätös 22). Ryhmä on <fieldset>, joten
6
+ * ruudunlukija sanoo otsikon ennen jokaista valintaa. Virhe kuuluu
7
+ * ryhmälle eikä yksittäiselle valinnalle: "valitse yksi" koskee
8
+ * kaikkia. Virhetilassa jokaisen valinnan reuna on --danger, ja viesti
9
+ * on ikonin kanssa kuten TextFieldissä.
10
+ *
11
+ * Yksittäinenkin pakollinen valinta ("hyväksy ehdot") tehdään ryhmänä,
12
+ * jotta virheelle on paikka.
13
+ */
14
+ export type ChoiceGroupProps = {
15
+ legend: string;
16
+ /** Ohjeteksti valintojen alla. */
17
+ hint?: string;
18
+ /** Virheviesti. Kun annettu, ryhmä on virhetilassa. */
19
+ error?: string;
20
+ children: ReactNode;
21
+ className?: string;
22
+ };
23
+ export default function ChoiceGroup({ legend, hint, error, children, className }: ChoiceGroupProps): import("react/jsx-runtime").JSX.Element;
package/ChoiceGroup.js ADDED
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import Icon from './Icon';
4
+ export default function ChoiceGroup({ legend, hint, error, children, className }) {
5
+ const id = useId();
6
+ const ohjeId = hint ? `${id}-ohje` : undefined;
7
+ const virheId = error ? `${id}-virhe` : undefined;
8
+ const kuvaus = [ohjeId, virheId].filter(Boolean).join(' ') || undefined;
9
+ return (_jsxs("fieldset", { className: ['choice-group', error && 'choice-group--invalid', className].filter(Boolean).join(' '), "aria-describedby": kuvaus, children: [_jsx("legend", { className: "choice-group__legend", children: legend }), children, hint ? (_jsx("p", { id: ohjeId, className: "field__hint", children: hint })) : null, error ? (_jsxs("p", { id: virheId, className: "field__error", children: [_jsx(Icon, { name: "alert" }), error] })) : null] }));
10
+ }
package/Icon.d.ts CHANGED
@@ -18,11 +18,14 @@
18
18
  * Koot ovat tokeneita: --icon-s (12) tekstin seassa metan kanssa,
19
19
  * --icon-m (16) napin sisällä, --icon-l (24) omana painikkeenaan.
20
20
  *
21
+ * `alert` kulkee aina virhetekstin rinnalla, ei yksin: merkitys on
22
+ * tekstissä, ikoni ja --danger vain nopeuttavat sen löytämistä.
23
+ *
21
24
  * Nuoli on kahta lajia eikä yhtä: `arrow-right` vie sivustolla eteen-
22
25
  * päin, `arrow-up-right` ulos. Ero on lukijalle sama kuin
23
26
  * target="_blank" ruudunlukijalle.
24
27
  */
25
- export type IconName = 'arrow-right' | 'arrow-up-right' | 'plus' | 'minus' | 'close' | 'menu';
28
+ export type IconName = 'arrow-right' | 'arrow-up-right' | 'plus' | 'minus' | 'close' | 'menu' | 'alert';
26
29
  export type IconSize = 's' | 'm' | 'l';
27
30
  export type IconProps = {
28
31
  name: IconName;
package/Icon.js CHANGED
@@ -8,6 +8,10 @@ const PATHS = {
8
8
  minus: ['M2.5 8.5 H14.5'],
9
9
  close: ['M3.5 3.5 L12.5 12.5', 'M12.5 3.5 L3.5 12.5'],
10
10
  menu: ['M1 5.5 H15', 'M1 10.5 H15'],
11
+ /* Virhe. Neliö eikä kolmio tai ympyrä: kaaria ei ole, ja kolmion
12
+ vinot sivut eivät osu puolikkaan pikselin ruudukolle. Piste on
13
+ lyhyt viiva, koska täyttöä ei ole. */
14
+ alert: ['M2.5 2.5 H14.5 V14.5 H2.5 Z', 'M8.5 5 V10', 'M8.5 11.5 V12.5'],
11
15
  };
12
16
  export default function Icon({ name, size = 'm', label, className }) {
13
17
  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))) }));
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * Sivun otsikko (pattern).
4
+ * ---------------------------------------------------------------
5
+ * Patterni ei tuo omaa ulkoasua: se asettelee olemassa olevat
6
+ * typografialuokat aina samalla tavalla. Otsikko (`display-xl`) ja
7
+ * sivutieto (`meta`) ovat samalla rivillä leveämmällä ruudulla,
8
+ * ingressi (`body-l`) otsikon alla samassa sarakkeessa.
9
+ *
10
+ * Ennen tätä sama rakenne kirjoitettiin jokaiselle sivulle käsin, ja
11
+ * Figma Make arvasi sen: ingressi päätyi sarakkeisiin 9–12, irti
12
+ * otsikosta (päätös 21).
13
+ */
14
+ export default function PageHeader({ title, meta, lede, }: {
15
+ title: ReactNode;
16
+ meta?: ReactNode;
17
+ lede?: ReactNode;
18
+ }): import("react/jsx-runtime").JSX.Element;
package/PageHeader.js ADDED
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Sivun otsikko (pattern).
4
+ * ---------------------------------------------------------------
5
+ * Patterni ei tuo omaa ulkoasua: se asettelee olemassa olevat
6
+ * typografialuokat aina samalla tavalla. Otsikko (`display-xl`) ja
7
+ * sivutieto (`meta`) ovat samalla rivillä leveämmällä ruudulla,
8
+ * ingressi (`body-l`) otsikon alla samassa sarakkeessa.
9
+ *
10
+ * Ennen tätä sama rakenne kirjoitettiin jokaiselle sivulle käsin, ja
11
+ * Figma Make arvasi sen: ingressi päätyi sarakkeisiin 9–12, irti
12
+ * otsikosta (päätös 21).
13
+ */
14
+ export default function PageHeader({ title, meta, lede, }) {
15
+ return (_jsxs("header", { className: "page page-header", children: [_jsxs("div", { className: "page-head", children: [_jsx("h1", { className: "display-xl", children: title }), meta ? _jsx("p", { className: "meta", children: meta }) : null] }), lede ? _jsx("p", { className: "body-l measure page-header__lede", children: lede }) : null] }));
16
+ }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pokela/components
2
2
 
3
- React-komponentit: **Pokela Design System**, versio 2.7.0.
3
+ React-komponentit: **Pokela Design System**, versio 2.10.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
@@ -23,6 +23,13 @@ tyylit saa myös suoraan: `@pokela/components/styles.css`.
23
23
  - `Accordion`
24
24
  - `Timeline`
25
25
  - `ExplodedView`
26
+ - `PageHeader`
27
+ - `Section`
28
+ - `TextField`
29
+ - `Checkbox`
30
+ - `Radio`
31
+ - `Switch`
32
+ - `ChoiceGroup`
26
33
  - `Grid`
27
34
  - `Reveal`
28
35
  - `ThemeScript`
@@ -57,6 +64,9 @@ import Link from 'next/link';
57
64
  - `styles/accordion.css`
58
65
  - `styles/timeline.css`
59
66
  - `styles/exploded-view.css`
67
+ - `styles/patterns.css`
68
+ - `styles/text-field.css`
69
+ - `styles/choice.css`
60
70
 
61
71
  Luokat ovat globaaleja, eivät CSS-moduuleja. Se on tarkoituksellista: sama
62
72
  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/Section.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * Osio (pattern).
4
+ * ---------------------------------------------------------------
5
+ * Sivun osio otsikkoineen. Kaksi asettelua, molemmat sivustolta:
6
+ *
7
+ * stacked otsikko ja sivutieto rivinä, sisältö alla
8
+ * (työlista, Tietoa, System)
9
+ * aside otsikko vasemmassa sarakkeessa, sisältö oikealla;
10
+ * pienellä ruudulla päällekkäin (casesivujen lohkot)
11
+ *
12
+ * Otsikko on aina `h2.meta`: se nimeää osion, sisällön oma otsikko
13
+ * (esim. `display-m`) kuuluu `children`-osaan.
14
+ */
15
+ export type SectionLayout = 'stacked' | 'aside';
16
+ export default function Section({ title, meta, id, layout, className, children, }: {
17
+ title: ReactNode;
18
+ meta?: ReactNode;
19
+ /** Otsikon id; osio nimetään sillä (`aria-labelledby`). */
20
+ id?: string;
21
+ layout?: SectionLayout;
22
+ className?: string;
23
+ children: ReactNode;
24
+ }): import("react/jsx-runtime").JSX.Element;
package/Section.js ADDED
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export default function Section({ title, meta, id, layout = 'stacked', className, children, }) {
3
+ const otsikko = (_jsx("h2", { id: id, className: "meta", children: title }));
4
+ if (layout === 'aside') {
5
+ return (_jsxs("section", { className: ['page section--aside', className].filter(Boolean).join(' '), "aria-labelledby": id, children: [otsikko, children] }));
6
+ }
7
+ return (_jsxs("section", { className: ['page section', className].filter(Boolean).join(' '), "aria-labelledby": id, children: [_jsxs("div", { className: "section-head", children: [otsikko, meta ? _jsx("span", { className: "meta", children: meta }) : null] }), children] }));
8
+ }
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/TextField.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { type InputHTMLAttributes } from 'react';
2
+ /**
3
+ * Tekstikenttä: label, kenttä, ohjeteksti ja virhe yhtenä.
4
+ *
5
+ * Sivusto ei käytä tätä. Se on kirjastossa sovellusnäkymiä varten
6
+ * (päätös 22), jotta Make voi rakentaa
7
+ * lomakkeen systeemin omista osista eikä keksi omaansa.
8
+ *
9
+ * Kieli on .btn-perheen: hiusviiva ilman pyöristystä, hover paksuntaa
10
+ * alaviivan kuten tekstinapissa, focus on sivuston yhteinen rengas.
11
+ * Virhe on systeemin ainoa tilaväri --danger, aina ikonin ja tekstin
12
+ * kanssa, ettei merkitys jää värin varaan.
13
+ *
14
+ * Tilat ovat propseja, eivät luokkia: `error` ja `disabled`. Hover ja
15
+ * focus ovat CSS:n tiloja. `multiline` vaihtaa kentän tekstialueeksi;
16
+ * muuten sama komponentti, koska label, ohje ja virhe ovat samat.
17
+ */
18
+ type Yhteiset = {
19
+ label: string;
20
+ /** Ohjeteksti kentän alla. */
21
+ hint?: string;
22
+ /** Virheviesti. Kun annettu, kenttä on virhetilassa (aria-invalid). */
23
+ error?: string;
24
+ /** Valinnaisen kentän merkintä labelin perässä, esim. "(valinnainen)". */
25
+ optional?: string;
26
+ className?: string;
27
+ };
28
+ export type TextFieldProps = Yhteiset & Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> & {
29
+ /** Tekstialue (<textarea>) yhden rivin kentän sijaan. */
30
+ multiline?: boolean;
31
+ /** Tekstialueen rivit. */
32
+ rows?: number;
33
+ };
34
+ export default function TextField(props: TextFieldProps): import("react/jsx-runtime").JSX.Element;
35
+ export {};
package/TextField.js ADDED
@@ -0,0 +1,20 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import Icon from './Icon';
4
+ export default function TextField(props) {
5
+ const { label, hint, error, optional, className, multiline, rows, id: annettuId, ...kentta } = props;
6
+ const omaId = useId();
7
+ const id = annettuId ?? omaId;
8
+ const ohjeId = hint ? `${id}-ohje` : undefined;
9
+ const virheId = error ? `${id}-virhe` : undefined;
10
+ const kuvaus = [ohjeId, virheId].filter(Boolean).join(' ') || undefined;
11
+ const yhteiset = {
12
+ id,
13
+ className: 'field__control',
14
+ 'aria-invalid': error ? true : undefined,
15
+ 'aria-describedby': kuvaus,
16
+ };
17
+ return (_jsxs("div", { className: ['field', error && 'field--invalid', kentta.disabled && 'field--disabled', className]
18
+ .filter(Boolean)
19
+ .join(' '), children: [_jsxs("label", { className: "field__label", htmlFor: id, children: [label, optional ? _jsxs("span", { className: "field__optional", children: [" ", optional] }) : null] }), multiline ? (_jsx("textarea", { ...kentta, rows: rows, ...yhteiset })) : (_jsx("input", { ...kentta, ...yhteiset })), 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] }));
20
+ }
@@ -8,6 +8,13 @@ Use these instead of building an equivalent.
8
8
  - `Accordion`, `useAccordionGroup`
9
9
  - `Timeline`, `TimelineItem`
10
10
  - `ExplodedView`
11
+ - `PageHeader`
12
+ - `Section`
13
+ - `TextField`
14
+ - `Checkbox`
15
+ - `Radio`
16
+ - `Switch`
17
+ - `ChoiceGroup`
11
18
  - `Grid`, `Col`
12
19
  - `Reveal`
13
20
  - `ThemeScript`, `THEME_STORAGE_KEY`
@@ -69,4 +76,55 @@ reader, described by `label`.
69
76
  />
70
77
  ```
71
78
 
79
+ `TextField` is the form field: label, input, hint and error in one. Use it
80
+ for every text input and text area (`multiline`) instead of a bare `<input>`.
81
+ Pass `error` with a message to show the error state; never colour a field
82
+ yourself. Mark an optional field with `optional="(optional)"`, not an asterisk
83
+ on the required ones. Put form buttons after the fields with the `.btn`
84
+ classes.
85
+
86
+ ```jsx
87
+ <TextField label="Email" type="email" error="The address is missing a domain." />
88
+ <TextField label="Message" optional="(optional)" multiline hint="Up to 500 characters." />
89
+ ```
90
+
91
+ `Checkbox`, `Radio` and `Switch` are the choices; put them inside a
92
+ `ChoiceGroup`, which gives the group its title (`legend`), `hint` and `error`.
93
+ Use `Checkbox` for options saved with a button, `Radio` (same `name`) when
94
+ exactly one option is picked, and `Switch` for a setting that takes effect at
95
+ once, never in a form with a save button. An error belongs to the group, not
96
+ to a single choice; a single required choice ("accept the terms") is still a
97
+ `ChoiceGroup`.
98
+
99
+ ```jsx
100
+ <ChoiceGroup legend="Visibility" error="Pick one.">
101
+ <Radio name="visibility" value="public" label="Public" hint="Anyone with the link." />
102
+ <Radio name="visibility" value="team" label="Team only" />
103
+ </ChoiceGroup>
104
+ <ChoiceGroup legend="Notifications">
105
+ <Switch label="Email notifications" defaultChecked />
106
+ </ChoiceGroup>
107
+ ```
108
+
109
+ ## Patterns
110
+
111
+ `PageHeader` and `Section` are patterns: they add no look of their own, only
112
+ the layout every page uses. Build pages from them instead of arranging
113
+ headings and grids yourself.
114
+
115
+ `PageHeader` is the page's title with an optional `meta` and `lede`. The lede
116
+ always sits under the title in the same column, never beside it.
117
+
118
+ `Section` names a part of the page with a small `title` (and optional
119
+ `meta`). `layout="stacked"` puts the title row above the content;
120
+ `layout="aside"` puts the title in a narrow left column and the content
121
+ beside it. Use one layout per page.
122
+
123
+ ```jsx
124
+ <PageHeader title="From idea to launch" meta="9 weeks" lede="Each phase ends in a shared decision." />
125
+ <Section title="Road to launch" layout="aside">
126
+ <Timeline>…</Timeline>
127
+ </Section>
128
+ ```
129
+
72
130
  `Reveal` fades its children in on scroll. Wrap sections, not single elements.
@@ -1,5 +1,27 @@
1
1
  # Setup
2
2
 
3
+ ## Before you build
4
+
5
+ First decide which of three jobs this is. If the request does not make it
6
+ clear, ask this one question and wait: **"Are you building a view, a new
7
+ component, or a change to an existing component?"**
8
+
9
+ 1. **A view or prototype**, new or existing. Ask what the page is, what is
10
+ on it and what can be clicked. Build it only from this package: pages
11
+ start with `PageHeader`, content goes in `Section`s. If the view needs a
12
+ part the package does not have, do not invent it inside the view: build
13
+ it separately as in 2 and label it "Proposed component".
14
+ 2. **A new component.** Ask its name, what it does, its states, and which
15
+ existing component is closest. Build it alone on an empty page, every
16
+ state visible side by side, using only tokens. Name its props like the
17
+ package's components (`variant`, `size`, `title`, `meta`), so it can
18
+ become a pull request and a story as is.
19
+ 3. **A change to an existing component.** Package components cannot be
20
+ edited here. Show the change as a new state next to the current one and
21
+ label it "Proposed change".
22
+
23
+ ## Install
24
+
3
25
  Install **both** packages as direct dependencies, at the same, latest
4
26
  version. `@pokela/components` uses the tokens of the same release.
5
27
 
@@ -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 6 marks<!-- /generated --> on
33
+ **No icon libraries and no emoji.** Use `Icon`. <!-- generated:make-ikonit -->It draws its own 7 marks<!-- /generated --> on
34
34
  a 16×16 grid with the same hairline as every border.
@@ -8,10 +8,15 @@ inversion — `--invert-surface` with `--invert-ink` — or the quiet
8
8
  `--badge-surface`. Do not introduce a brand blue, a primary green, or any
9
9
  coloured button. Colour on the page comes only from photographs.
10
10
 
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
13
+ with the `alert` icon and a message, so the meaning never depends on colour.
14
+
11
15
  **No shadows.** There are no shadow tokens. Separation is a 1px line
12
16
  (`--hairline`), never elevation.
13
17
 
14
- **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.
15
20
 
16
21
  <!-- generated:make-valit -->**No spacing outside the scale.** 16 steps, each named by its value:<!-- /generated -->
17
22
  `--space-24` is 24px. If a value is missing, use the nearest step rather than
package/index.d.ts CHANGED
@@ -9,6 +9,19 @@ export { default as Timeline, TimelineItem } from './Timeline.js';
9
9
  export type { TimelineVariant } from './Timeline.js';
10
10
  export { default as ExplodedView } from './ExplodedView.js';
11
11
  export type { ExplodedTila, ExplodedLayer } from './ExplodedView.js';
12
+ export { default as PageHeader } from './PageHeader.js';
13
+ export { default as Section } from './Section.js';
14
+ export type { SectionLayout } from './Section.js';
15
+ export { default as TextField } from './TextField.js';
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';
12
25
  export { Grid, Col } from './Grid.js';
13
26
  export { default as Reveal } from './Reveal.js';
14
27
  export { default as ThemeScript, THEME_STORAGE_KEY } from './ThemeScript.js';
package/index.js CHANGED
@@ -5,6 +5,13 @@ export { default as ListRow } from './ListRow.js';
5
5
  export { Accordion, useAccordionGroup } from './Accordion.js';
6
6
  export { default as Timeline, TimelineItem } from './Timeline.js';
7
7
  export { default as ExplodedView } from './ExplodedView.js';
8
+ export { default as PageHeader } from './PageHeader.js';
9
+ export { default as Section } from './Section.js';
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';
8
15
  export { Grid, Col } from './Grid.js';
9
16
  export { default as Reveal } from './Reveal.js';
10
17
  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.7.0",
3
+ "version": "2.10.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.7.0"
30
+ "@pokela/tokens": "^2.10.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,38 @@
1
+ /* Patternit: sivun otsikko ja osio (PageHeader, Section).
2
+ Eivät tuo omaa ulkoasua, vain asettelun olemassa oleville
3
+ typografialuokille. Siirretty work.css:stä ja case.css:stä, jotta
4
+ ne lähtevät paketin mukana (päätös 21). */
5
+
6
+ .page-head {
7
+ padding-top: var(--space-40);
8
+ padding-bottom: var(--space-32);
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: var(--space-12);
12
+ }
13
+ @media (min-width: 600px) {
14
+ .page-head { flex-direction: row; align-items: baseline; justify-content: space-between; gap: var(--space-24); }
15
+ }
16
+ @media (min-width: 900px) { .page-head { padding-top: var(--space-56); padding-bottom: var(--space-40); } }
17
+
18
+ /* Ingressi otsikon alla, samassa sarakkeessa. */
19
+ .page-header__lede { margin: 0; padding-bottom: var(--space-40); }
20
+
21
+ .section-head {
22
+ display: flex;
23
+ align-items: baseline;
24
+ justify-content: space-between;
25
+ gap: var(--space-16);
26
+ padding-bottom: var(--space-16);
27
+ }
28
+
29
+ .section--aside {
30
+ display: grid;
31
+ grid-template-columns: minmax(0, 1fr);
32
+ gap: var(--space-20);
33
+ padding-block: var(--section-gap);
34
+ border-top: var(--hairline) solid var(--line);
35
+ }
36
+ @media (min-width: 900px) {
37
+ .section--aside { grid-template-columns: 200px minmax(0, 1fr); gap: var(--space-40); }
38
+ }
@@ -0,0 +1,91 @@
1
+ /* Tekstikenttä (TextField). Sovellusnäkymien komponentti, ei sivuston.
2
+ ==================================================================
3
+ Sama kieli kuin .btn: hiusviiva, ei pyöristystä, ei varjoa. Kenttä
4
+ on 48 px, nappia (44) korkeampi: 16 px:n teksti rivivälillä 1.65
5
+ tarvitsee tilaa, ja kirjoitettava pinta saa olla painettavaa
6
+ suurempi. */
7
+
8
+ .field { display: grid; gap: var(--space-8); align-content: start; }
9
+
10
+ .field__label {
11
+ font-size: var(--text-body-s);
12
+ line-height: var(--lh-body-s);
13
+ font-weight: var(--weight-500);
14
+ color: var(--ink);
15
+ }
16
+ .field__optional { font-weight: var(--weight-400); color: var(--ink-faint); }
17
+
18
+ .field__control {
19
+ appearance: none;
20
+ width: 100%;
21
+ margin: 0;
22
+ /* 16 px eikä body-s: iOS zoomaa alle 16 px:n kentät. */
23
+ font: inherit;
24
+ font-size: var(--text-body);
25
+ line-height: var(--lh-body);
26
+ color: var(--ink);
27
+ background: var(--paper);
28
+ height: var(--space-48);
29
+ padding: 0 var(--space-16);
30
+ border: var(--hairline) solid var(--line-strong);
31
+ border-radius: var(--radius);
32
+ transition: border-color var(--dur-fast) var(--ease-standard);
33
+ }
34
+ .field__control::placeholder { color: var(--ink-faint); opacity: 1; }
35
+
36
+ textarea.field__control {
37
+ height: auto;
38
+ min-height: var(--space-128);
39
+ padding: var(--space-12) var(--space-16);
40
+ resize: vertical;
41
+ }
42
+
43
+ /* Hover = alaviiva paksunee, sama liike kuin .btn--text:llä. Kenttä
44
+ on border-box ja kiinteän korkuinen, joten mitään ei nytkähdä. */
45
+ .field__control:hover:not(:disabled) { border-bottom-width: var(--hairline-strong); }
46
+
47
+ .field__hint {
48
+ margin: 0;
49
+ font-size: var(--text-body-s);
50
+ line-height: var(--lh-body-s);
51
+ color: var(--ink-muted);
52
+ }
53
+
54
+ /* ---- virhe ------------------------------------------------------- */
55
+
56
+ /* Koko reuna paksunee, ei vain alaviiva: virhe ei saa näyttää
57
+ hoverilta. Padding kevenee saman verran, joten teksti pysyy
58
+ paikallaan. */
59
+ .field--invalid .field__control {
60
+ border-color: var(--danger);
61
+ border-width: var(--hairline-strong);
62
+ padding-inline: calc(var(--space-16) - (var(--hairline-strong) - var(--hairline)));
63
+ }
64
+ .field--invalid textarea.field__control {
65
+ padding-block: calc(var(--space-12) - (var(--hairline-strong) - var(--hairline)));
66
+ }
67
+
68
+ .field__error {
69
+ margin: 0;
70
+ display: flex;
71
+ gap: var(--space-8);
72
+ align-items: flex-start;
73
+ font-size: var(--text-body-s);
74
+ line-height: var(--lh-body-s);
75
+ font-weight: var(--weight-500);
76
+ color: var(--danger);
77
+ }
78
+ /* Ikoni ensimmäisen rivin keskelle: (rivinkorkeus − ikoni) / 2. */
79
+ .field__error .icon { margin-top: calc((var(--lh-body-s) * var(--text-body-s) - var(--icon-m)) / 2); }
80
+
81
+ /* ---- disabled ---------------------------------------------------- */
82
+
83
+ /* Sama kuin .btn[disabled]: viiva haalenee, teksti faint. Pinta on
84
+ paper-alt, jotta tyhjäkin kenttä erottuu käytössä olevasta. */
85
+ .field--disabled .field__label { color: var(--ink-faint); }
86
+ .field__control:disabled {
87
+ background: var(--paper-alt);
88
+ border-color: var(--line);
89
+ color: var(--ink-faint);
90
+ cursor: not-allowed;
91
+ }
package/styles.css CHANGED
@@ -7,3 +7,6 @@
7
7
  @import './styles/accordion.css';
8
8
  @import './styles/timeline.css';
9
9
  @import './styles/exploded-view.css';
10
+ @import './styles/patterns.css';
11
+ @import './styles/text-field.css';
12
+ @import './styles/choice.css';