@pokela/components 2.14.0 → 2.16.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/Dialog.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Modaalinen dialogi: otsikko, teksti, sisältö, toiminnot ja sulkeminen.
4
+ *
5
+ * Sovelluskomponentti (päätökset 22 ja 31). Ei varjoa eikä tummaa
6
+ * verhoa: sivu peittyy paperin sävyisellä verholla, ja dialogi on
7
+ * paperia musteen hiusviivakehyksellä. Uutta väriä ei tarvita, ja
8
+ * sama ratkaisu toimii molemmissa teemoissa. Mobiilissa dialogi on
9
+ * alareunan arkki, ja napit ovat täysleveitä.
10
+ *
11
+ * Natiivi <dialog> ja showModal(): selain pitää fokuksen dialogissa,
12
+ * piilottaa muun sivun ruudunlukijalta ja sulkee Escillä. Esc ja
13
+ * verhon klikkaus kutsuvat `onClose`a, joten tila pysyy kutsujalla.
14
+ *
15
+ * Fokus menee avattaessa ensimmäiseen fokusoitavaan: lomakkeessa
16
+ * ensimmäiseen kenttään, vahvistuksessa Peruuta-nappiin, kun se on
17
+ * toiminnoissa ensimmäisenä. Sulkunappi on DOM:ssa viimeisenä, jotta
18
+ * se ei vie alkufokusta. Suljettaessa fokus palaa avanneeseen nappiin.
19
+ */
20
+ export type DialogProps = {
21
+ open: boolean;
22
+ onClose: () => void;
23
+ title: string;
24
+ /** Selittävä teksti otsikon alla. Ruudunlukija lukee sen avattaessa. */
25
+ description?: ReactNode;
26
+ /** Sisältö tekstin alla, esim. lomakkeen kentät. */
27
+ children?: ReactNode;
28
+ /** Napit oikeaan alakulmaan: vähiten vahingollinen ensin. */
29
+ actions?: ReactNode;
30
+ /** Sulkunapin nimi ruudunlukijalle. */
31
+ closeLabel?: string;
32
+ className?: string;
33
+ };
34
+ export default function Dialog({ open, onClose, title, description, children, actions, closeLabel, className, }: DialogProps): import("react/jsx-runtime").JSX.Element;
package/Dialog.js ADDED
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef } from 'react';
4
+ import Icon from './Icon';
5
+ export default function Dialog({ open, onClose, title, description, children, actions, closeLabel = 'Sulje', className, }) {
6
+ const ref = useRef(null);
7
+ const avaaja = useRef(null);
8
+ const otsikkoId = useId();
9
+ const tekstiId = useId();
10
+ useEffect(() => {
11
+ const el = ref.current;
12
+ if (!el)
13
+ return;
14
+ if (open && !el.open) {
15
+ avaaja.current = document.activeElement;
16
+ el.showModal();
17
+ }
18
+ else if (!open && el.open) {
19
+ el.close();
20
+ avaaja.current?.focus();
21
+ }
22
+ }, [open]);
23
+ return (_jsx("dialog", { ref: ref, className: ['dialog', className].filter(Boolean).join(' '), "aria-labelledby": otsikkoId, "aria-describedby": description ? tekstiId : undefined, onCancel: (e) => {
24
+ /* Esc: selain sulkisi itse, mutta tila on kutsujalla. */
25
+ e.preventDefault();
26
+ onClose();
27
+ }, onClick: (e) => {
28
+ /* Klikkaus osuu itse dialogiin vain verhon kohdalla: sisältö on
29
+ .dialog__panelissa, joka täyttää dialogin. */
30
+ if (e.target === e.currentTarget)
31
+ onClose();
32
+ }, children: _jsxs("div", { className: "dialog__panel", children: [_jsxs("div", { className: "dialog__header", children: [_jsx("h2", { id: otsikkoId, className: "dialog__title", children: title }), description ? (_jsx("div", { id: tekstiId, className: "dialog__description", children: description })) : null] }), children ? _jsx("div", { className: "dialog__body", children: children }) : null, actions ? _jsx("div", { className: "dialog__actions", children: actions }) : null, _jsx("button", { type: "button", className: "dialog__close", "aria-label": closeLabel, onClick: onClose, children: _jsx(Icon, { name: "close" }) })] }) }));
33
+ }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pokela/components
2
2
 
3
- React-komponentit: **Pokela Design System**, versio 2.14.0.
3
+ React-komponentit: **Pokela Design System**, versio 2.16.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
@@ -34,6 +34,8 @@ tyylit saa myös suoraan: `@pokela/components/styles.css`.
34
34
  - `Tabs`
35
35
  - `Tab`
36
36
  - `Alert`
37
+ - `Table`
38
+ - `Dialog`
37
39
  - `Grid`
38
40
  - `Reveal`
39
41
  - `ThemeScript`
@@ -74,6 +76,8 @@ import Link from 'next/link';
74
76
  - `styles/select.css`
75
77
  - `styles/tabs.css`
76
78
  - `styles/alert.css`
79
+ - `styles/table.css`
80
+ - `styles/dialog.css`
77
81
 
78
82
  Luokat ovat globaaleja, eivät CSS-moduuleja. Se on tarkoituksellista: sama
79
83
  luokka on myös Figmassa komponentin nimenä, ja hajautettu nimi katkaisisi
package/Table.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Taulukko: sarakkeet, rivit, lajittelu ja tyhjä tila.
4
+ *
5
+ * Sovelluskomponentti (päätökset 22 ja 30). Rivit erotetaan
6
+ * hiusviivalla, otsikkorivin alla on musteviiva. Ei pystyviivoja,
7
+ * raitoja eikä pintaa: sama kieli kuin ListRow'ssa. Otsikot ovat
8
+ * metatekstiä, jotta huomio menee dataan.
9
+ *
10
+ * Saavutettavuus:
11
+ * · oikea <table>, jolla on <caption> (oletuksena piilotettu) ja
12
+ * <th scope="col">
13
+ * · lajiteltava otsikko on <th>:n sisällä oleva nappi, ja <th>
14
+ * kertoo järjestyksen aria-sortilla (APG: Sortable Table)
15
+ * · kun taulukko ei mahdu, vierityslaatikosta tulee nimetty alue,
16
+ * johon pääsee sarkaimella ja jota voi vierittää nuolilla
17
+ *
18
+ * Lajittelu tehdään täällä: rivit annetaan sellaisinaan, ja taulukko
19
+ * järjestää ne valitun sarakkeen mukaan. Tila voi olla ohjattu (`sort`
20
+ * + `onSortChange`) tai oma (`defaultSort`).
21
+ */
22
+ export type TableSortDirection = 'ascending' | 'descending';
23
+ export type TableSort = {
24
+ key: string;
25
+ direction: TableSortDirection;
26
+ };
27
+ export type TableColumn<R> = {
28
+ /** Sarakkeen avain. Ilman `cell`iä solu näyttää `row[key]`. */
29
+ key: string;
30
+ header: string;
31
+ /** Luku: oikealle ja tasalevyisin numeroin. */
32
+ numeric?: boolean;
33
+ /** Toissijainen tieto --ink-mutedilla. */
34
+ muted?: boolean;
35
+ sortable?: boolean;
36
+ /** Otsikko vain ruudunlukijalle, esim. toimintosarake. */
37
+ hideHeader?: boolean;
38
+ cell?: (row: R) => ReactNode;
39
+ /** Arvo, jonka mukaan lajitellaan. Oletus `row[key]`. */
40
+ sortValue?: (row: R) => string | number;
41
+ };
42
+ export type TableProps<R> = {
43
+ /** Taulukon nimi. Ruudunlukija lukee sen aina. */
44
+ caption: string;
45
+ /** Näytä nimi myös näkyvästi taulukon yläpuolella. */
46
+ showCaption?: boolean;
47
+ columns: TableColumn<R>[];
48
+ rows: R[];
49
+ rowKey: (row: R) => string;
50
+ sort?: TableSort | null;
51
+ defaultSort?: TableSort | null;
52
+ onSortChange?: (sort: TableSort) => void;
53
+ /** Teksti, kun rivejä ei ole. */
54
+ empty?: ReactNode;
55
+ className?: string;
56
+ };
57
+ export default function Table<R>({ caption, showCaption, columns, rows, rowKey, sort, defaultSort, onSortChange, empty, className, }: TableProps<R>): import("react/jsx-runtime").JSX.Element;
package/Table.js ADDED
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import Icon from './Icon';
5
+ const vertailija = new Intl.Collator('fi', { numeric: true, sensitivity: 'base' });
6
+ function arvo(rivi, sarake) {
7
+ if (sarake.sortValue)
8
+ return sarake.sortValue(rivi);
9
+ const v = rivi[sarake.key];
10
+ return typeof v === 'number' ? v : String(v ?? '');
11
+ }
12
+ export default function Table({ caption, showCaption = false, columns, rows, rowKey, sort, defaultSort = null, onSortChange, empty = 'Ei rivejä.', className, }) {
13
+ const [omaSort, setOmaSort] = useState(defaultSort);
14
+ const nykyinen = sort !== undefined ? sort : omaSort;
15
+ const jarjestetty = useMemo(() => {
16
+ const sarake = nykyinen && columns.find((c) => c.key === nykyinen.key);
17
+ if (!nykyinen || !sarake)
18
+ return rows;
19
+ const suunta = nykyinen.direction === 'ascending' ? 1 : -1;
20
+ return [...rows].sort((a, b) => {
21
+ const x = arvo(a, sarake);
22
+ const y = arvo(b, sarake);
23
+ const ero = typeof x === 'number' && typeof y === 'number' ? x - y : vertailija.compare(String(x), String(y));
24
+ return ero * suunta;
25
+ });
26
+ }, [rows, columns, nykyinen]);
27
+ const lajittele = (key) => {
28
+ const seuraava = {
29
+ key,
30
+ direction: nykyinen?.key === key && nykyinen.direction === 'ascending' ? 'descending' : 'ascending',
31
+ };
32
+ if (sort === undefined)
33
+ setOmaSort(seuraava);
34
+ onSortChange?.(seuraava);
35
+ };
36
+ /* Vierityslaatikko on alue ja sarkainpysäkki vain, kun se oikeasti
37
+ vierittyy: muuten se olisi turha pysäkki jokaisella näppäimistön
38
+ käyttäjällä. `data-scrolled` näyttää kiinnitetyn sarakkeen viivan
39
+ vasta, kun sen alle on vieritetty jotain. */
40
+ const laatikko = useRef(null);
41
+ const [vierittyy, setVierittyy] = useState(false);
42
+ useEffect(() => {
43
+ const el = laatikko.current;
44
+ if (!el)
45
+ return;
46
+ const mittaa = () => setVierittyy(el.scrollWidth > el.clientWidth + 1);
47
+ const vieritys = () => el.toggleAttribute('data-scrolled', el.scrollLeft > 0);
48
+ mittaa();
49
+ const ro = new ResizeObserver(mittaa);
50
+ ro.observe(el);
51
+ el.addEventListener('scroll', vieritys, { passive: true });
52
+ return () => {
53
+ ro.disconnect();
54
+ el.removeEventListener('scroll', vieritys);
55
+ };
56
+ }, []);
57
+ const captionId = useId();
58
+ return (_jsx("div", { ref: laatikko, className: ['table', className].filter(Boolean).join(' '), role: vierittyy ? 'region' : undefined, "aria-labelledby": vierittyy ? captionId : undefined, tabIndex: vierittyy ? 0 : undefined, children: _jsxs("table", { className: "table__table", children: [_jsx("caption", { id: captionId, className: showCaption ? 'table__caption' : 'visually-hidden', children: caption }), _jsx("thead", { children: _jsx("tr", { children: columns.map((c) => {
59
+ const suunta = nykyinen?.key === c.key ? nykyinen.direction : undefined;
60
+ return (_jsx("th", { scope: "col", className: c.numeric ? 'table__num' : undefined, "aria-sort": c.sortable ? (suunta ?? 'none') : undefined, children: c.hideHeader ? (_jsx("span", { className: "visually-hidden", children: c.header })) : c.sortable ? (_jsxs("button", { type: "button", className: "table__sort", "data-direction": suunta, onClick: () => lajittele(c.key), children: [c.header, _jsx(Icon, { name: "chevron-down", size: "s", className: "table__sort-icon" })] })) : (c.header) }, c.key));
61
+ }) }) }), _jsx("tbody", { children: jarjestetty.length === 0 ? (_jsx("tr", { className: "table__empty", children: _jsx("td", { colSpan: columns.length, children: empty }) })) : (jarjestetty.map((rivi) => (_jsx("tr", { children: columns.map((c) => (_jsx("td", { className: [c.numeric && 'table__num', c.muted && 'table__muted'].filter(Boolean).join(' ') || undefined, children: c.cell ? c.cell(rivi) : String(rivi[c.key] ?? '') }, c.key))) }, rowKey(rivi))))) })] }) }));
62
+ }
@@ -19,6 +19,8 @@ Use these instead of building an equivalent.
19
19
  - `Tabs`
20
20
  - `Tab`
21
21
  - `Alert`
22
+ - `Table`
23
+ - `Dialog`
22
24
  - `Grid`, `Col`
23
25
  - `Reveal`
24
26
  - `ThemeScript`, `THEME_STORAGE_KEY`
@@ -160,6 +162,50 @@ for something that went wrong, never for emphasis. There is no warning tone.
160
162
  </Alert>
161
163
  ```
162
164
 
165
+ `Table` is for data people compare across rows: members, invoices, logs.
166
+ Never use it for layout or for a list of links (use `ListRow`). Pass
167
+ `columns` and `rows`; mark numbers `numeric` (right-aligned, tabular) and
168
+ secondary text `muted`. `sortable` columns sort when the header is clicked;
169
+ set `defaultSort` for the initial order. Always give a `caption`; it is read
170
+ by screen readers even when hidden. Status in a cell is an icon and text in
171
+ `table__status` (`table__status--error` for failures), never colour alone; a
172
+ row action is a `table__action` link whose name includes the row.
173
+
174
+ ```jsx
175
+ <Table
176
+ caption="Team members"
177
+ columns={[
178
+ { key: 'name', header: 'Name', sortable: true },
179
+ { key: 'changes', header: 'Changes', numeric: true, sortable: true },
180
+ ]}
181
+ rows={members}
182
+ rowKey={(m) => m.id}
183
+ defaultSort={{ key: 'name', direction: 'ascending' }}
184
+ empty="No members yet. Invite the first one."
185
+ />
186
+ ```
187
+
188
+ `Dialog` is a modal for one decision or a short form: confirm a deletion,
189
+ invite a member. Never use it for content people read, and never stack two.
190
+ Control it with `open` and `onClose`; Esc and a click on the veil call
191
+ `onClose`. Give a short `title` (a question for confirmations), the
192
+ consequence as `description`, form fields as children, and buttons in
193
+ `actions` with the least destructive first (`btn--ghost` Cancel, then
194
+ `btn--primary`). Use `btn--s` buttons. On mobile it becomes a bottom sheet.
195
+
196
+ ```jsx
197
+ <Dialog
198
+ open={open}
199
+ onClose={() => setOpen(false)}
200
+ title="Remove Sara from the team?"
201
+ description="Access ends immediately. You can invite them again later."
202
+ actions={<>
203
+ <button className="btn btn--ghost btn--s" onClick={() => setOpen(false)}>Cancel</button>
204
+ <button className="btn btn--primary btn--s" onClick={remove}>Remove member</button>
205
+ </>}
206
+ />
207
+ ```
208
+
163
209
  ## Patterns
164
210
 
165
211
  `PageHeader` and `Section` are patterns: they add no look of their own, only
@@ -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`, `ChoiceGroup`, `Select` and `Alert` already apply it, always
12
+ for emphasis, buttons or decoration. `TextField`, `ChoiceGroup`, `Select`, `Alert` and `Table` 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
@@ -30,6 +30,10 @@ export { default as Tab } from './Tab.js';
30
30
  export type { TabProps } from './Tab.js';
31
31
  export { default as Alert } from './Alert.js';
32
32
  export type { AlertTone, AlertProps } from './Alert.js';
33
+ export { default as Table } from './Table.js';
34
+ export type { TableSortDirection, TableSort, TableColumn, TableProps } from './Table.js';
35
+ export { default as Dialog } from './Dialog.js';
36
+ export type { DialogProps } from './Dialog.js';
33
37
  export { Grid, Col } from './Grid.js';
34
38
  export { default as Reveal } from './Reveal.js';
35
39
  export { default as ThemeScript, THEME_STORAGE_KEY } from './ThemeScript.js';
package/index.js CHANGED
@@ -16,6 +16,8 @@ export { default as Select } from './Select.js';
16
16
  export { default as Tabs } from './Tabs.js';
17
17
  export { default as Tab } from './Tab.js';
18
18
  export { default as Alert } from './Alert.js';
19
+ export { default as Table } from './Table.js';
20
+ export { default as Dialog } from './Dialog.js';
19
21
  export { Grid, Col } from './Grid.js';
20
22
  export { default as Reveal } from './Reveal.js';
21
23
  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.14.0",
3
+ "version": "2.16.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.14.0"
30
+ "@pokela/tokens": "^2.16.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": ">=18"
@@ -0,0 +1,125 @@
1
+ /* Dialogi (Dialog). Sovelluskomponentti, sivusto ei käytä tätä.
2
+ ==================================================================
3
+ Paperiverho ja hiusviivakehys (päätös 31). Systeemissä ei ole
4
+ varjoja eikä verhoväriä: sivu peittyy paperin sävyisellä verholla,
5
+ ja dialogi erottuu siitä musteviivalla. Verho on läpikuultavaa
6
+ paperia, joten se toimii samoin molemmissa teemoissa.
7
+
8
+ Mobiili ensin: alareunan arkki, jonka yläreunassa on viiva. 600 px
9
+ alkaen keskitetty dialogi, jonka ympärillä on kehys. */
10
+
11
+ .dialog {
12
+ inset: auto 0 0;
13
+ width: 100%;
14
+ max-width: 100%;
15
+ max-height: calc(100dvh - var(--space-32));
16
+ margin: 0;
17
+ padding: 0;
18
+ overflow-y: auto;
19
+ overscroll-behavior: contain;
20
+ background: var(--paper);
21
+ color: var(--ink);
22
+ border: 0;
23
+ border-top: var(--hairline) solid var(--ink);
24
+ }
25
+ .dialog::backdrop {
26
+ background: color-mix(in srgb, var(--paper) 88%, transparent);
27
+ }
28
+
29
+ /* Sivu ei vierity dialogin alla. */
30
+ html:has(.dialog[open]) { overflow: hidden; }
31
+
32
+ .dialog__panel {
33
+ position: relative;
34
+ display: grid;
35
+ gap: var(--space-24);
36
+ padding: var(--space-24) var(--space-16) var(--space-32);
37
+ }
38
+
39
+ .dialog__header {
40
+ display: grid;
41
+ gap: var(--space-8);
42
+ /* Tila sulkunapille, ettei pitkä otsikko mene sen alle. */
43
+ padding-right: var(--space-40);
44
+ }
45
+ .dialog__title {
46
+ margin: 0;
47
+ font-family: var(--font-ui);
48
+ font-size: var(--text-body-l);
49
+ line-height: var(--lh-body-l);
50
+ font-weight: var(--weight-500);
51
+ }
52
+ .dialog__description {
53
+ font-size: var(--text-body-s);
54
+ line-height: var(--lh-body-s);
55
+ color: var(--ink-muted);
56
+ }
57
+ .dialog__description > * { margin: 0; }
58
+
59
+ .dialog__body {
60
+ display: grid;
61
+ gap: var(--space-16);
62
+ }
63
+
64
+ /* Mobiilissa napit täysleveinä allekkain, ensisijainen (viimeinen)
65
+ ylimpänä peukalon ulottuvilla. */
66
+ .dialog__actions {
67
+ display: flex;
68
+ flex-direction: column-reverse;
69
+ gap: var(--space-12);
70
+ }
71
+ .dialog__actions > .btn { width: 100%; }
72
+
73
+ /* Sulkunappi: näkyvä 32 px, osuma-alue --tap-min, kuten Alertissa. */
74
+ .dialog__close {
75
+ position: absolute;
76
+ top: var(--space-16);
77
+ right: var(--space-8);
78
+ display: inline-flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ width: var(--space-32);
82
+ height: var(--space-32);
83
+ padding: 0;
84
+ background: none;
85
+ border: 0;
86
+ color: var(--ink-muted);
87
+ cursor: pointer;
88
+ }
89
+ .dialog__close::after {
90
+ content: '';
91
+ position: absolute;
92
+ inset: calc((var(--space-32) - var(--tap-min)) / 2);
93
+ }
94
+ .dialog__close:hover { color: var(--ink); }
95
+
96
+ @media (min-width: 600px) {
97
+ .dialog {
98
+ inset: 0;
99
+ width: calc(100% - var(--space-32));
100
+ max-width: 480px;
101
+ margin: auto;
102
+ border: var(--hairline) solid var(--ink);
103
+ }
104
+ .dialog__panel { padding: var(--space-32); }
105
+ .dialog__close { top: var(--space-24); right: var(--space-24); }
106
+ .dialog__actions {
107
+ flex-direction: row;
108
+ justify-content: flex-end;
109
+ }
110
+ .dialog__actions > .btn { width: auto; }
111
+ }
112
+
113
+ /* Liike: dialogi nousee ja verho häivyttää. Ei liikettä, jos käyttäjä
114
+ on sen kieltänyt. */
115
+ @media (prefers-reduced-motion: no-preference) {
116
+ .dialog[open] { animation: dialog-esiin var(--dur-base) var(--ease-out); }
117
+ .dialog[open]::backdrop { animation: dialog-verho var(--dur-base) var(--ease-out); }
118
+ .dialog__close { transition: color var(--dur-fast) var(--ease-standard); }
119
+ }
120
+ @keyframes dialog-esiin { from { opacity: 0; transform: translateY(var(--space-8)); } }
121
+ @keyframes dialog-verho { from { opacity: 0; } }
122
+
123
+ @media (forced-colors: active) {
124
+ .dialog::backdrop { background: Canvas; opacity: 0.88; }
125
+ }
@@ -0,0 +1,184 @@
1
+ /* Taulukko (Table). Sovelluskomponentti, sivusto ei käytä tätä.
2
+ ==================================================================
3
+ Rivit hiusviivalla, otsikkorivin alla musteviiva (päätös 30). Ei
4
+ pystyviivoja, raitoja eikä pintaa. Otsikot ovat metatekstiä, data
5
+ Body/S:ää. Rivi on vähintään --tap-min, jotta solun linkkiin ja
6
+ nappiin osuu sormella.
7
+
8
+ Laatikolla on 8 px sivusisennys samasta syystä kuin välilehtirivillä:
9
+ se vierittyy, ja vierittyvä laatikko leikkaisi fokusrenkaan reunoilta.
10
+ Sisennys kumotaan negatiivisella marginaalilla, joten taulukko alkaa
11
+ palstan reunasta. Laatikon oma fokusrengas on sivuston oletus, ulkona:
12
+ sisäpuolinen jäisi kiinnitetyn sarakkeen alle. */
13
+
14
+ .table {
15
+ margin-inline: calc(var(--space-8) * -1);
16
+ padding-inline: var(--space-8);
17
+ overflow-x: auto;
18
+ }
19
+
20
+ .table__table {
21
+ width: 100%;
22
+ border-collapse: separate;
23
+ border-spacing: 0;
24
+ font-size: var(--text-body-s);
25
+ line-height: var(--lh-body-s);
26
+ color: var(--ink);
27
+ }
28
+
29
+ .table__caption {
30
+ padding-bottom: var(--space-12);
31
+ font-weight: var(--weight-500);
32
+ text-align: left;
33
+ }
34
+
35
+ .table th,
36
+ .table td {
37
+ height: var(--tap-min);
38
+ padding: 0 var(--space-16) 0 0;
39
+ text-align: left;
40
+ vertical-align: middle;
41
+ white-space: nowrap;
42
+ }
43
+ .table th:last-child,
44
+ .table td:last-child { padding-right: 0; }
45
+
46
+ .table th {
47
+ font-family: var(--font-ui);
48
+ font-size: var(--text-meta);
49
+ line-height: var(--lh-meta);
50
+ font-weight: var(--weight-600);
51
+ letter-spacing: var(--tr-meta);
52
+ text-transform: uppercase;
53
+ color: var(--ink-muted);
54
+ border-bottom: var(--hairline) solid var(--ink);
55
+ }
56
+ .table th[aria-sort='ascending'],
57
+ .table th[aria-sort='descending'] { color: var(--ink); }
58
+
59
+ .table td { border-bottom: var(--hairline) solid var(--line); }
60
+
61
+ .table .table__num {
62
+ text-align: right;
63
+ font-variant-numeric: tabular-nums;
64
+ }
65
+ .table__muted { color: var(--ink-muted); }
66
+
67
+ /* Lajittelunappi täyttää otsikkosolun korkeuden, ja sen 8 px sivu-
68
+ sisennys kumotaan, jotta teksti pysyy sarakkeen linjassa ja
69
+ fokusrengas mahtuu napin sisään. Nuoli on näkymätön, kunnes sarake
70
+ on lajiteltu, tai näkyy haaleana hoverissa ja fokuksessa: se kertoo,
71
+ että saraketta voi lajitella. Nouseva järjestys osoittaa ylös. */
72
+ .table__sort {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ gap: var(--space-4);
76
+ /* Hiusviiva on rivin alareunassa, joten nappi on sen verran matalampi. */
77
+ min-height: calc(var(--tap-min) - var(--hairline));
78
+ margin: 0 calc(var(--space-8) * -1);
79
+ padding: 0 var(--space-8);
80
+ background: none;
81
+ border: 0;
82
+ font: inherit;
83
+ letter-spacing: inherit;
84
+ text-transform: inherit;
85
+ color: inherit;
86
+ cursor: pointer;
87
+ }
88
+ .table__sort-icon { opacity: 0; }
89
+ .table__sort:hover,
90
+ .table__sort:focus-visible { color: var(--ink); }
91
+ .table__sort:hover .table__sort-icon,
92
+ .table__sort:focus-visible .table__sort-icon { opacity: 1; color: var(--ink-faint); }
93
+ .table__sort[data-direction] .table__sort-icon { opacity: 1; color: var(--ink); }
94
+ .table__sort[data-direction='ascending'] .table__sort-icon { transform: rotate(180deg); }
95
+ .table__sort:focus-visible {
96
+ outline: var(--focus-width) solid var(--ink);
97
+ outline-offset: calc(var(--focus-width) * -1);
98
+ }
99
+ @media (prefers-reduced-motion: no-preference) {
100
+ .table__sort { transition: color var(--dur-fast) var(--ease-standard); }
101
+ .table__sort-icon { transition: opacity var(--dur-fast) var(--ease-standard); }
102
+ }
103
+
104
+ /* Rivi korostuu vain osoittimen alla, ei kosketuksella: napautus ei
105
+ saa jättää riviä korostetuksi. */
106
+ @media (hover: hover) {
107
+ .table tbody tr:not(.table__empty):hover td { background: var(--paper-alt); }
108
+ }
109
+
110
+ /* Ensimmäinen sarake pysyy paikallaan, kun taulukkoa vieritetään.
111
+ Varjo täyttää laatikon 8 px sisennyksen, jotta alle vierittyvä teksti
112
+ ei näy sen läpi. Viiva tulee vasta, kun jotain on vierittynyt alle. */
113
+ .table th:first-child,
114
+ .table td:first-child {
115
+ position: sticky;
116
+ left: 0;
117
+ z-index: 1;
118
+ background: var(--paper);
119
+ box-shadow: calc(var(--space-8) * -1) 0 0 var(--paper);
120
+ }
121
+ .table[data-scrolled] th:first-child,
122
+ .table[data-scrolled] td:first-child {
123
+ box-shadow:
124
+ calc(var(--space-8) * -1) 0 0 var(--paper),
125
+ inset calc(var(--hairline) * -1) 0 0 var(--line);
126
+ }
127
+ @media (hover: hover) {
128
+ .table tbody tr:not(.table__empty):hover td:first-child {
129
+ box-shadow: calc(var(--space-8) * -1) 0 0 var(--paper);
130
+ }
131
+ .table[data-scrolled] tbody tr:not(.table__empty):hover td:first-child {
132
+ box-shadow:
133
+ calc(var(--space-8) * -1) 0 0 var(--paper),
134
+ inset calc(var(--hairline) * -1) 0 0 var(--line);
135
+ }
136
+ }
137
+
138
+ .table__empty td {
139
+ position: static;
140
+ height: auto;
141
+ padding: var(--space-32) 0;
142
+ color: var(--ink-muted);
143
+ white-space: normal;
144
+ }
145
+
146
+ /* Toiminto solussa, esim. Muokkaa. Alleviivaus tekstin omalla viivalla
147
+ eikä .btn--textin alareunalla, jotta teksti pysyy rivin muun tekstin
148
+ linjassa. Osuma-alue on rivin korkuinen. */
149
+ .table__action {
150
+ position: relative;
151
+ color: var(--ink);
152
+ text-decoration: underline;
153
+ text-decoration-thickness: var(--hairline);
154
+ text-underline-offset: var(--space-4);
155
+ }
156
+ .table__action:hover {
157
+ text-decoration: underline;
158
+ text-decoration-thickness: var(--hairline-strong);
159
+ }
160
+ .table__action::after {
161
+ content: '';
162
+ position: absolute;
163
+ top: 50%;
164
+ left: 0;
165
+ right: 0;
166
+ height: var(--tap-min);
167
+ transform: translateY(-50%);
168
+ }
169
+
170
+ /* Tila solussa: ikoni ja teksti, ei koskaan pelkkä väri. Virhe on
171
+ --danger kuten kentässä ja ilmoituksessa. */
172
+ /* Lohko eikä inline-flex: ikonilla ei ole perusviivaa, joten
173
+ inline-flex nostaisi tekstin rivin muun tekstin yläpuolelle. */
174
+ .table__status {
175
+ display: flex;
176
+ align-items: center;
177
+ gap: var(--space-8);
178
+ }
179
+ .table__status--error { color: var(--danger); }
180
+
181
+ @media (forced-colors: active) {
182
+ .table th:first-child,
183
+ .table td:first-child { box-shadow: none; }
184
+ }
package/styles.css CHANGED
@@ -13,3 +13,5 @@
13
13
  @import './styles/select.css';
14
14
  @import './styles/tabs.css';
15
15
  @import './styles/alert.css';
16
+ @import './styles/table.css';
17
+ @import './styles/dialog.css';