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.

TP-IAM
TP-MEDIA
TP-PERUSTUS
TP-TTK
TP-VASTAANOTTO
TableFrame
NimiTuotenumeroOstohintaTila
Aplus A703 265/65R17W12890000029,86 €
Perustamatta
Continental IceContact 3 155/65R14W12890013737,16 €
Ajossa
Battery for MT9557, 3,87VW12890027444,46 €
Perustettu
Cradle (black) for NQuire 10W12890041151,76 €
Perustamatta
Aplus A703 265/65R17W12890054859,06 €
Ajossa
Continental IceContact 3 155/65R14W12890068566,36 €
Perustettu
Battery for MT9557, 3,87VW12890082273,66 €
Perustamatta
Cradle (black) for NQuire 10W12890095980,96 €
Ajossa
Aplus A703 265/65R17W12890109688,26 €
Perustettu
Continental IceContact 3 155/65R14W12890123395,56 €
Perustamatta
Battery for MT9557, 3,87VW128901370102,86 €
Ajossa
Cradle (black) for NQuire 10W128901507110,16 €
Perustettu
1–50 / 61 957
Esimerkki valitulla teemalla ja sävyllä; vaihda ne yläpalkista.

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>
PropsiMerkitys
sortKeySarakkeen tunniste
sort / onSortLajittelutila ja sen muuttaja
defaultDirectionSuunta ensimmäisellä klikkauksella. Luvuille desc on lähes aina oikein
alignright numerosarakkeille
labelSarakkeen 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). locale on 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>
FunktioTekee
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ä.

PropsiMerkitys
columns · rows · rowKeypakolliset; rowKey on vakaa avain, ei indeksi
emptytyhjän tuloksen teksti — kirjoita se aina
loading · skeletonRowsluurankorivit
errorsyrjäyttää rivit ja tyhjän tilan, role="alert"
truncated{ shown, total, hint } — katkaisuilmoitus
pagination · onPageChangevalmiiksi sivutetun datan tila + takaisinkutsu
onRowClicklisää 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.tsx
  • src/components/table-frame-pagination.tsx
  • src/components/data-table.tsx
  • src/components/sortable-table-head.tsx
  • src/components/sort-button.tsx
  • src/components/column-resize-handle.tsx