דילוג לתוכן הראשי
Components

Sort / filter

UkFilters, filter and sort any collection, and UkSheet, the panel it opens on a phone. One demo uses it: realty.

#uk-filters

UkFilters

<Component />

Filter and sort any collection from a config next to its tinyCMS data; the state is in the URL.

פעם נדל״ן: listings

סוג עסקה

6 נכסים

דירת 3 חדרים משופצת בשינקיןלמכירה

דירת 3 חדרים משופצת בשינקין

‏3,450,000 ‏₪

3 חדרים · 78 מ״ר · קומה 3

תל אביב, שינקין

דירת 4 חדרים עם מרפסת שמשלמכירה

דירת 4 חדרים עם מרפסת שמש

‏2,950,000 ‏₪

4 חדרים · 102 מ״ר · קומה 6

רמת גן, ביאליק

דירת 4 חדרים עם נוף ליםלמכירה

דירת 4 חדרים עם נוף לים

‏1,890,000 ‏₪

4 חדרים · 110 מ״ר · קומה 2

חיפה, מוריה

דירת גן ברחוב עמק רפאיםלמכירה

דירת גן ברחוב עמק רפאים

‏3,150,000 ‏₪

3.5 חדרים · 88 מ״ר · קומת קרקע

ירושלים, עמק רפאים

דירת 4 חדרים קרוב לאוניברסיטהלמכירה

דירת 4 חדרים קרוב לאוניברסיטה

‏1,420,000 ‏₪

4 חדרים · 105 מ״ר · קומה 4

באר שבע, שדרות רגר

דירת 5 חדרים עם שתי חניותלמכירה

דירת 5 חדרים עם שתי חניות

‏3,250,000 ‏₪

5 חדרים · 132 מ״ר · קומה 1

מודיעין, עמק החולה

Cards rendered on the server; under about 1040px the panel moves into the sheet.

A deep link, built with filtersHref: דירות להשכרה מ-3 חדרים opens in a new tab, pre-filtered.

פעם מתכון: recipes

סוג מנה

12 מתכונים

פאד תאי עם טופומנה עיקרית

פאד תאי עם טופו

תאילנדי · 30 דק׳ · בינוני

אטריות אורז, טופו, נבטים, בוטנים, ליים

קארי ירקות בחלב קוקוסמנה עיקרית

קארי ירקות בחלב קוקוס

תאילנדי · 40 דק׳ · בינוני

חלב קוקוס, חציל, בזיליקום תאילנדי, אורז

מרק טום יאם עם שרימפסמנה ראשונה

מרק טום יאם עם שרימפס

תאילנדי · 25 דק׳ · קל

שרימפס, למון גראס, פטריות, ליים

טאקו עוףמנה עיקרית

טאקו עוף

מקסיקני · 35 דק׳ · קל

טורטיות תירס, עוף, אבוקדו, כוסברה, בצל סגול

גוואקמולימנה ראשונה

גוואקמולי

מקסיקני · 10 דק׳ · קל

אבוקדו, ליים, בצל סגול, כוסברה

תירס בגריל בסגנון מקסיקנימנה ראשונה

תירס בגריל בסגנון מקסיקני

מקסיקני · 20 דק׳ · קל

תירס, גבינה, צ׳ילי, ליים

מוצגים 6 מתוך 12

Rendered on the client: two renderers behind a UkToggleList, six at a time.

Code

// listings/listings-filters.ts: next to listings-data.ts. No function in it (JSON-safe);
// every path is checked against Listing ('price.amout' is a TS error)
import { defineFilters } from '@/components/uikit/UkFilters/uk-filters-engine'
import type { Listing } from './listings-data'

export const listingFilters = defineFilters<Listing>({
  fields: [
    { id: 'deal', kind: 'choice', look: 'tabs', label: 'סוג עסקה', all: false,
      options: [{ value: 'sale', label: 'למכירה' }, { value: 'rent', label: 'להשכרה' }] },
    { id: 'city', kind: 'choice', look: 'select', label: 'עיר', get: 'location.city', all: 'כל הערים', order: 'label' },
    { id: 'rooms', kind: 'atLeast', label: 'חדרים', steps: [2, 3, 4, 5] },
    { id: 'price', kind: 'range', label: 'מחיר', get: 'price.amount',
      format: { style: 'currency', currency: 'ILS', maximumFractionDigits: 0 } },
    { id: 'sqm', kind: 'range', label: 'שטח', step: 5, format: { suffix: ' מ״ר' } },
    { id: 'features', kind: 'choice', look: 'pills', label: 'מאפיינים', match: 'all' },
    { id: 'parking', kind: 'toggle', label: 'חניה' },
    { id: 'ground', kind: 'toggle', label: 'קומת קרקע', get: 'floor', is: 0 },
  ],
  search: { in: ['title', 'location.city', 'location.street'], placeholder: 'עיר או רחוב' },
  sorts: [
    { id: 'new', label: 'חדשים באתר' },
    { id: 'price-asc', label: 'מחיר: מהנמוך לגבוה', by: 'price.amount' },
    { id: 'price-desc', label: 'מחיר: מהגבוה לנמוך', by: 'price.amount', dir: 'desc' },
    { id: 'sqm', label: 'שטח: מהגדול לקטן', by: 'sqm', dir: 'desc' },
  ],
  presets: [{ label: 'למשפחות', query: 'rooms=4&parking=1' }, { label: 'עם גינה', query: 'features=גינה' }],
  labels: { count: { zero: 'לא נמצאו נכסים', one: 'נכס אחד', other: '{n} נכסים' } },
})

// listings/ListingsFilters.tsx: the island, a few lines. The config is imported
// here (it may hold functions, which cannot travel from a server page as a prop)
'use client'
import type { ReactNode } from 'react'
import UkFilters from '@/components/uikit/UkFilters'
import { listingFilters } from './listings-filters'
import type { Listing } from './listings-data'

export default function ListingsFilters(props: { items: Listing[]; cards: Record<string, ReactNode> }) {
  return <UkFilters config={listingFilters} {...props} />
}

// listings/page.tsx: a Server Component. Every card is rendered here, once;
// the island only hides and reorders them, so every listing is in the HTML
<section className="uk-section">
  <div className="uk-container uk-container-large">
    <h1>דירות למכירה ולהשכרה</h1>
    <div className="uk-spacer-v-32" />
    <ListingsFilters
      items={listingList}
      cards={Object.fromEntries(listingList.map((l) => [l.slug, <ListingCard key={l.slug} listing={l} />]))}
    />
  </div>
</section>

Props

  • itemsreadonly T[]required
    The collection. Object.values(record) for a tinyCMS Record. Must be serializable when it comes from a server page (a StaticImageData picture is).
  • configUkFiltersConfig<T>required
    From defineFilters, built once at module scope (its identity keys the index; a dev warning fires if it changes between renders). Imported in the client wrapper, so it may hold functions.
  • cardsRecord<string, ReactNode>
    Server-rendered cards keyed by config.key (slug). The island only hides and reorders them: no card code ships to the client. Exactly one of cards / render (TS refuses both and neither).
  • render(visible, api) => ReactNode
    Client rendering, inside the 'use client' wrapper: two renderers behind a UkToggleList, a table, a map. Re-run when the visible items change, not on a keystroke or when the sheet opens.
  • layout'sidebar' | 'bar'default 'sidebar'
    sidebar: an aside at the inline start once the kit is 56rem wide, the sheet under it. bar: never an aside, one "סינון" button opens the sheet at every width (Airbnb's Filters button).
  • listClassNamestringdefault 'uk-grid @xl:uk-grid-2 @3xl:uk-grid-3'
    cards only: the results wrapper. Its columns follow the RESULTS column's width (container variants: @xl 36rem, @3xl 48rem), not the screen. uk-list-rows gives the row look.
  • pageSizenumberdefault all
    Load more: "הצגת עוד 12" with "מוצגים 12 מתוך 40" under it. Back to page one on every change.
  • actionsReactNode
    The end of the status row, beside the sort: a UkToggleList.
  • emptyReactNode
    Replaces the empty state's message. The ways out ("בלי חניה · 4 נכסים") and "ניקוי הכל" still render under it.
  • urlboolean | stringdefault true
    true: plain keys (?city=חיפה). A string: a key prefix, for two kits on one page ('l-' gives ?l-city=…). false: state only.
  • initialQuerystring | searchParams
    The state the server renders: a dynamic page's await searchParams, or a fixed slice ('deal=rent') on a static landing page. A query in the URL still wins.
  • stickybooleandefault true
    The bar sticks under --uk-filters-top whenever there is no aside (a phone: just the search row).
  • onChange(state, results) => void
    After every change the visitor makes: analytics, a map beside the list.
  • classNamestringdefault ''
    On the root. Inside a flex or grid parent give it a width (w-full min-w-0): it queries its own width.

#uk-sheet

UkSheet

<Component />

A panel a button opens over the page: a bottom sheet on a phone, a side sheet from 640.

Live: a branch picker

side, from 640:

עוד לא נבחר סניף.

On a touch screen it drags down to close.

Code

// a 'use client' island; the page around it stays a Server Component
import { useState } from 'react'
import UkSheet from '@/components/uikit/UkSheet'
import UkButton from '@/components/uikit/UkButton/UkButton';

export default function BranchPicker() {
  const [open, setOpen] = useState(false)
  return (
    <>
      <UkButton type="button"
        aria-haspopup="dialog" aria-expanded={open} aria-controls="branch-sheet"
        onClick={() => setOpen(true)} variant="outline">
        בחירת סניף
      </UkButton>
      <UkSheet
        id="branch-sheet"
        open={open}
        onClose={() => setOpen(false)}        // once per closing, whatever closed it
        title="בחירת סניף"                    // the dialog's name (aria-labelledby)
        describedBy="branch-intro"
        footer={<>
          <UkButton type="button" data-uk-sheet-close="" variant="text">ביטול</UkButton>
          <UkButton type="submit" form="branch-form">בחירת הסניף</UkButton>
        </>}
      >
        <p id="branch-intro">בחרו את הסניף הקרוב אליכם.</p>
        <form id="branch-form" onSubmit={(e) => { e.preventDefault(); /* keep the choice */ setOpen(false) }}>
          …radios…
        </form>
      </UkSheet>
    </>
  )
}

Props

  • openbooleanrequired
    True opens it (showModal()); turning it false closes it.
  • onClose() => voidrequired
    The dialog's own close event, once per closing, whatever closed it: the ×, Esc, a tap on the backdrop, a swipe, the Android back button (the dialog's cancel), a [data-uk-sheet-close] button, open turning false. Set open to false here.
  • titleReactNode
    The head's text, in a <p> (not a heading: the page keeps its own outline); its id is the dialog's aria-labelledby. Without it, aria-label is required (TS checks).
  • aria-labelstring
    The dialog's name when there is no visible title.
  • childrenReactNoderequired
    The body. It scrolls by itself (data-lenis-prevent, overscroll-behavior: contain), padded 1rem by 1.25rem, and mounts on opening, unmounts on closing.
  • footerReactNode
    A foot that stays at the bottom, with a hairline over it and the safe-area padding under it. One child sits at the end; two spread to the ends (a quiet one at the start, the action at the end). A button with data-uk-sheet-close closes the sheet.
  • side'end' | 'start' | 'bottom'default 'end'
    Where it comes from, from 640: a full-height side sheet from the inline end (the left in RTL) or start, or a bottom sheet, centred and --uk-sheet-w wide. On a phone it is always the bottom sheet.
  • swipebooleandefault true
    The grip and swipe down to close, on a bottom sheet (a phone, or side="bottom" on a touch screen). The head drags; the body drags only when nothing under the finger is scrolled down (the body, or a box inside it with its own scroll), decided on the first move; a slider thumb is left alone. Past a quarter of its height, or a flick (still moving as the finger lifts), it slides out, else it springs back; a second finger puts it back too. Touch only.
  • maxHeightstringdefault 88dvh
    The bottom sheet's height cap. Sets --uk-sheet-max-h on this sheet.
  • widthstringdefault 26rem
    The side sheet's width from 640, never more than the screen less 2rem. Sets --uk-sheet-w on this sheet.
  • closeLabelstringdefault 'סגירה'
    The ×'s name for a screen reader.
  • noteReactNode
    An sr-only description, read when the sheet opens (aria-describedby). UkFilters passes "כל שינוי חל מיד על התוצאות".
  • describedBystring
    The id of a visible element in the body that describes the sheet; joins note in aria-describedby.
  • liveReactNode
    A polite live region inside the modal, always mounted, so opening announces nothing. A modal makes the page behind it inert, its live regions included: UkFilters puts its count here.
  • initialFocusRefObject<HTMLElement | null>default the ×
    What takes focus on opening. The × by default, so no keyboard pops up on a phone.
  • keepMountedbooleandefault false
    Keep the content mounted while closed, so a half-filled form survives a swipe.
  • idstring
    The dialog's id, for the trigger's aria-controls.
  • classNamestring
    More classes on the <dialog>: a kit's hook (uk-filters__sheet).