TableFrame
Taulukkokehys
Taulukko kehyksessä: tahmea työkalurivi, sarakkeet datana, lajittelu, leveydensäätö ja sivutus.
Käyttöpaikat
Suoraan: palvelu tuo osan itse. Välillisesti: osa tulee mukaan toisen paketin osan kautta, ja sen muutos näkyy palvelussa silti. Laskettu julkaisun yhteydessä 23.10.2026.
Käyttö
Paketin README:n luku sellaisenaan.
SortableTableHead — taulukon lajittelu
Klikattava sarakeotsikko ja vertailufunktio. Alkuperäinen toteutus on AIMO:n, siirretty pakettiin 2026-08-11.
import {
SortableTableHead, sortRows, Table, TableBody, TableHeader, TableRow,
type SortState,
} from "@karkkainen/ui";
const [sort, setSort] = useState<SortState<"nimi" | "luottamus">>({
key: null, direction: "asc",
});
const sorted = sortRows(rows, sort, (row, key) => row[key]);
<SortableTableHead sortKey="nimi" sort={sort} onSort={setSort}>
Toimittaja
</SortableTableHead>
<SortableTableHead
sortKey="luottamus" sort={sort} onSort={setSort}
defaultDirection="desc" align="right"
>
Luottamus
</SortableTableHead>| Propsi | Merkitys |
|---|---|
sortKey | Sarakkeen tunniste |
sort / onSort | Lajittelutila ja sen muuttaja |
defaultDirection | Suunta ensimmäisellä klikkauksella. Luvuille desc on lähes aina oikein |
align | right numerosarakkeille |
label | Sarakkeen nimi ruudunlukijalle. Pakollinen jos `children` ei ole pelkkää tekstiä |
key: null tarkoittaa palvelimen omaa järjestystä. Saman sarakkeen uudelleenklikkaus kääntää suunnan; kolmatta tilaa ei ole tarkoituksella — sivun päivitys riittää siihen eikä kolmiaskelinen kierto ole arvattavissa.
compareValues — kolme asiaa jotka menisivät helposti väärin
compareValues(a, b, direction, locale = "fi")
- Tyhjät arvot pysyvät lopussa molemmissa suunnissa. Naiivi toteutus nostaisi puuttuvat arvot ylimmäksi laskevassa järjestyksessä, mikä on täsmälleen väärin: *"järjestä luottamuksen mukaan"* tarkoittaa että haluaa nähdä arvot, ei niiden puuttumista.
- Numeeriset merkkijonot vertaillaan numeroina —
"1056"ennen"7350":ää,"9"ennen"10":ä. - Aakkostus on suomalainen (
ä,öz:n jälkeen).localeon parametri, ei kovakoodattu.
0 ja false eivät ole tyhjiä vaikka ovatkin falsy.
Aikaleimat: timeValue
sortRows(rows, sort, (row, key) => key === "saapunut" ? timeValue(row.saapunut) : row[key], );
Muuntaa ISO-merkkijonon epoch-millisekunneiksi. Käytä sitä aina aikaleimasarakkeissa — compareValues käyttää numeric: true, joka pilkkoo numerojaksot, eikä sen käyttäytyminen aikaleimoilla ole ilmeistä.
null säilyy null:ina eikä muutu nollaksi: nolla olisi vuosi 1970 ja nousisi laskevassa järjestyksessä kärkeen. Kelvoton merkkijono käsitellään kuin puuttuva.
Palvelinpuolen lajittelu URL-parametreilla
Kun lista on osajoukko tietokannasta, selainlajittelu järjestää vain haetun joukon. Se on oikein niin kauan kuin lista ei kasva — ja väärin heti kun se kasvaa, ilman että mikään hajoaa näkyvästi. Silloin tila kulkee URL:ssa ja järjestys sovelletaan kyselyssä. Sivu saa samalla pysyä palvelinkomponenttina.
// Palvelinkomponentti
import { sortStateFromParams, sortRows } from "@karkkainen/ui";
const SARAKKEET = ["nimi", "saapunut", "koko"] as const;
export default async function Page({ searchParams }) {
const sort = sortStateFromParams(await searchParams, SARAKKEET);
const rows = await haeLahetykset({ orderBy: sort.key, dir: sort.direction });
…
}// Otsikkopainike (client) — router jää palvelulle
import { sortStateToParams } from "@karkkainen/ui";
<SortableTableHead
sortKey="nimi" sort={sort}
onSort={(next) =>
router.replace(`?${new URLSearchParams(sortStateToParams(next))}`)
}
>
Toimittaja
</SortableTableHead>| Funktio | Tekee |
|---|---|
sortStateToParams(sort, names?) | Tila → { jarjesta, suunta }. Tyhjä objekti kun lajittelua ei ole, jotta parametrit voi jättää pois osoitteesta |
sortStateFromParams(params, allowedKeys, options?) | Parametrit → tila. Hyväksyy URLSearchParamsin ja Next:in searchParams-objektin |
> ⚠️ `allowedKeys` on pakollinen, eikä syyttä. ?jarjesta= tulee > käyttäjältä ja päätyy usein suoraan ORDER BY:hyn. Tuntematon avain > palauttaa { key: null } eli palvelimen oman järjestyksen — ei virhettä, > koska katkennut linkki ei saa kaataa sivua.
Parametrien nimet voi ohittaa ({ names: { key: "sort", direction: "dir" } }), ja oletukset ovat luettavissa SORT_PARAM_NAMES:sta. Sitä tarvitaan kun päivität olemassa olevaa osoitetta: tyhjä paluuarvo ei kerro mitkä nimet pitäisi poistaa.
Useimmiten et tarvitse niitä käsin, koska applySortToParams hoitaa poiston ja asetuksen:
router.replace(`?${applySortToParams(searchParams, next)}`);Se säilyttää muut parametrit (suodattimet, sivunumerot) ja poistaa vanhat lajitteluparametrit myös silloin kun uutta lajittelua ei tule. Ei mutatoi syötettä. Hyväksyy URLSearchParamsin, Next:in searchParams-objektin tai merkkijonon.
> Sivutusta ei nollata. Lajittelun vaihtaminen sivulla 5 jättää käyttäjän > sivulle 5, mikä on harvoin haluttua — mutta parametrin nimi ja nollauksen > politiikka ovat palvelun asioita, eivät paketin. Poista se itse: > p.delete("sivu").
SORT_PARAM_NAMES — kaksi käyttötapaa
Ilmeinen käyttö on lukea oletukset osoitetta rakennettaessa. Hyödyllisempi on kiinnittää nimet itse ja verrata niitä testissä:
const OMAT_NIMET = { key: "jarjesta", direction: "suunta" } as const;
it("kiinnitetyt nimet vastaavat yhä paketin oletuksia", () => {
expect(OMAT_NIMET).toEqual(SORT_PARAM_NAMES);
});Syy: lajittelulinkki on kirjanmerkittävä osoite. Jos paketin oletukset joskus muuttuvat, tallennetut linkit lakkaisivat toimimasta hiljaa — mutta kiinnitettynä muutos näkyy testin kaatumisena eikä käyttäjän rikkinäisenä kirjanmerkkinä.
Vienti ei siis poista päällekkäisyyttä vaan tekee erosta havaittavan. Molemmat havainnot ovat TP-Vastaanotolta, jälkimmäinen heidän käyttöönottonsa jälkeen.
Paketti ei ota reititysriippuvuutta. Nämä ovat puhtaita funktioita ja router.replace jää palvelulle — sama linja kuin AppLauncherin LinkComponentilla: kehysside injektoidaan, ei tuoda riippuvuutena. Kuvio on TP-Vastaanotolta.
Missä lajittelu tapahtuu
sortRows järjestää sen mitä sille annetaan. Sivutetussa taulukossa se on väärin — se järjestäisi vain näkyvän sivun. Silloin komponentista tarvitaan vain SortState ja järjestys sovelletaan kyselyssä:
// Palvelinsivutettu: tila käyttöliittymästä, järjestys kyselystä
const rows = await haeTuotteet({ page, orderBy: sort.key, dir: sort.direction });> Raja jonka paketti vetää: design system omistaa kontrollit ja tilan > muodon, ei datan hankintaa. Sama koskee hakua — hakukenttä ja > "näytetään 100/5146" -viesti kuuluvat tänne, mutta se mistä ne 100 tulevat > ei. Muuten komponentti pakottaisi datan muistiin ja rikkoisi > palvelinsivutetut näkymät.
DataTable — taulukko jonka sarakkeet ovat dataa
const sarakkeet: DataTableColumn<Toimittaja>[] = [
{ key: "nimi", header: "Toimittaja", cell: (t) => t.nimi },
{ key: "tila", header: "Tila", cell: (t) => <StatusBadge …/> },
{ key: "maara", header: "Lähetyksiä", cell: (t) => t.maara, align: "right" },
{ key: "toimet", header: "", cell: (t) => <Valikko …/>, hidden: !canEdit },
];
<DataTable
columns={sarakkeet}
rows={toimittajat}
rowKey={(t) => t.id}
empty="Ei toimittajia. Lisää ensimmäinen yllä olevasta painikkeesta."
/>Komponentti omistaa rakenteen, ei sisältöä eikä reititystä.
| Propsi | Merkitys |
|---|---|
columns · rows · rowKey | pakolliset; rowKey on vakaa avain, ei indeksi |
empty | tyhjän tuloksen teksti — kirjoita se aina |
loading · skeletonRows | luurankorivit |
error | syrjäyttää rivit ja tyhjän tilan, role="alert" |
truncated | { shown, total, hint } — katkaisuilmoitus |
pagination · onPageChange | valmiiksi sivutetun datan tila + takaisinkutsu |
onRowClick | lisää cursor-pointerin ja näppäimistötuen |
> `colSpan` johdetaan sarakkeista. Se on komponentin tärkein tehtävä. > Käsin laskettu colSpan={isAdmin ? 7 : 6} on kopio sarakemäärästä ja > hajoaa hiljaa kun sarake lisätään — mikään ei kaadu, tyhjä rivi vain > levittäytyy väärin. hidden poistaa sarakkeen otsikosta, riveiltä ja > colSpanista yhdellä lipulla.
> ⚠️ Ei lajittele eikä sivuta dataa. rows renderöidään sellaisenaan. > Lajittelu tulee headerin kautta, joka on mikä tahansa node: > > ``tsx > { key: "nimi", header: <SortableTableHead sortKey="nimi" …>Toimittaja</SortableTableHead>, … } > `` > > Näin URL-parametrit ja router pysyvät palvelun hallussa — sama raja kuin > lajitteluapureissa.
Tyhjä tulos ja epäonnistunut haku eivät ole sama asia. error syrjäyttää empty:n; hakuvirheen näyttäminen tyhjänä tuloksena on väärä väite lopputuloksesta.
Sivutus ja katkaisu ovat eri asioita. pagination on navigointia ja piirtyy vain jos sivuja on enemmän kuin yksi. truncated on suorituskykyraja: *"näytetään 100 ensimmäistä, tarkenna hakua"*.
> Riviväli näytetään myös ilman `onPageChange`ia, ja kehitysajossa > puuttuva käsittelijä varoittaa konsoliin. AIMO:n rajapinta palautti > paginationin kuukausia, mutta käyttöliittymä luki siitä vain itemsin — > ruudulla oli 25 riviä viidestä tuhannesta eikä mikään kertonut siitä. > Puuttuva navigointi ei saa olla hiljainen.
> ⚠️ Älä esilataa seuraavaa sivua. Sivunvaihto ei ole kaikilla halpa > palvelinkutsu — osa palveluista viipaloi listan palvelimen muistissa, > jolloin page-vaihto lukee koko joukon uudelleen.
pageRange — rivivälin laskenta
pageRange({ page: 206, pageSize: 25, totalItems: 5146, totalPages: 206 })
// → { start: 5126, end: 5146 }Laskenta tulee sivutustilasta eikä rivimäärästä: rows.length on vain nykyisen sivun koko, eikä totalItems välttämättä tule samasta kyselystä kuin rivit.
Tuonti ja lähde
import { TableFrame, TableFramePagination, DataTable, SortableTableHead, SortButton, ColumnResizeHandle } from "@karkkainen/ui";src/components/table-frame.tsxsrc/components/table-frame-pagination.tsxsrc/components/data-table.tsxsrc/components/sortable-table-head.tsxsrc/components/sort-button.tsxsrc/components/column-resize-handle.tsx