למכירהדירת 3 חדרים משופצת בשינקין
3,450,000 ₪
תל אביב, שינקין
UkFilters, filter and sort any collection, and UkSheet, the panel it opens on a phone. One demo uses it: realty.
#uk-filters
Filter and sort any collection from a config next to its tinyCMS data; the state is in the URL.
6 נכסים
למכירה3,450,000 ₪
תל אביב, שינקין
למכירה2,950,000 ₪
רמת גן, ביאליק
למכירה1,890,000 ₪
חיפה, מוריה
למכירה3,150,000 ₪
ירושלים, עמק רפאים
למכירה1,420,000 ₪
באר שבע, שדרות רגר
למכירה3,250,000 ₪
מודיעין, עמק החולה
להשכרה7,200 ₪ לחודש
תל אביב, בן יהודה
להשכרה4,300 ₪ לחודש
חיפה, שדרות הנשיא
להשכרה6,400 ₪ לחודש
ירושלים, רמב״ן
להשכרה6,900 ₪ לחודש
רמת גן, ז׳בוטינסקי
להשכרה3,900 ₪ לחודש
באר שבע, העצמאות
להשכרה6,200 ₪ לחודש
מודיעין, עמק דותן
A deep link, built with filtersHref: דירות להשכרה מ-3 חדרים opens in a new tab, pre-filtered.
12 מתכונים
מנה עיקריתאטריות אורז, טופו, נבטים, בוטנים, ליים
מנה עיקריתחלב קוקוס, חציל, בזיליקום תאילנדי, אורז
מנה ראשונהשרימפס, למון גראס, פטריות, ליים
מנה עיקריתטורטיות תירס, עוף, אבוקדו, כוסברה, בצל סגול
מנה ראשונהאבוקדו, ליים, בצל סגול, כוסברה
מנה ראשונהתירס, גבינה, צ׳ילי, ליים
מוצגים 6 מתוך 12
// 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>itemsreadonly T[]requiredObject.values(record) for a tinyCMS Record. Must be serializable when it comes from a server page (a StaticImageData picture is).configUkFiltersConfig<T>requireddefineFilters, 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>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'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 allactionsReactNodeemptyReactNodeurlboolean | stringdefault truetrue: plain keys (?city=חיפה). A string: a key prefix, for two kits on one page ('l-' gives ?l-city=…). false: state only.initialQuerystring | searchParamsawait searchParams, or a fixed slice ('deal=rent') on a static landing page. A query in the URL still wins.stickybooleandefault true--uk-filters-top whenever there is no aside (a phone: just the search row).onChange(state, results) => voidclassNamestringdefault ''w-full min-w-0): it queries its own width.| Prop | Type | Default | What |
|---|---|---|---|
items | readonly 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). |
config | UkFiltersConfig<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. |
cards | Record<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' | '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). |
listClassName | string | '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. |
pageSize | number | all | Load more: "הצגת עוד 12" with "מוצגים 12 מתוך 40" under it. Back to page one on every change. |
actions | ReactNode | - | The end of the status row, beside the sort: a UkToggleList. |
empty | ReactNode | - | Replaces the empty state's message. The ways out ("בלי חניה · 4 נכסים") and "ניקוי הכל" still render under it. |
url | boolean | string | true | true: plain keys (?city=חיפה). A string: a key prefix, for two kits on one page ('l-' gives ?l-city=…). false: state only. |
initialQuery | string | 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. |
sticky | boolean | 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. |
className | string | '' | 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
A panel a button opens over the page: a bottom sheet on a phone, a side sheet from 640.
עוד לא נבחר סניף.
// 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>
</>
)
}openbooleanrequiredshowModal()); turning it false closes it.onClose() => voidrequiredcancel), a [data-uk-sheet-close] button, open turning false. Set open to false here.titleReactNode<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-labelstringchildrenReactNoderequireddata-lenis-prevent, overscroll-behavior: contain), padded 1rem by 1.25rem, and mounts on opening, unmounts on closing.footerReactNodedata-uk-sheet-close closes the sheet.side'end' | 'start' | 'bottom'default 'end'--uk-sheet-w wide. On a phone it is always the bottom sheet.swipebooleandefault trueside="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--uk-sheet-max-h on this sheet.widthstringdefault 26rem--uk-sheet-w on this sheet.closeLabelstringdefault 'סגירה'noteReactNodearia-describedby). UkFilters passes "כל שינוי חל מיד על התוצאות".describedBystringnote in aria-describedby.liveReactNodeinitialFocusRefObject<HTMLElement | null>default the ×keepMountedbooleandefault falseidstringaria-controls.classNamestring<dialog>: a kit's hook (uk-filters__sheet).| Prop | Type | Default | What |
|---|---|---|---|
open | boolean | required | True opens it (showModal()); turning it false closes it. |
onClose | () => void | required | 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. |
title | ReactNode | - | 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-label | string | - | The dialog's name when there is no visible title. |
children | ReactNode | required | The body. It scrolls by itself (data-lenis-prevent, overscroll-behavior: contain), padded 1rem by 1.25rem, and mounts on opening, unmounts on closing. |
footer | ReactNode | - | 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' | '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. |
swipe | boolean | 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. |
maxHeight | string | 88dvh | The bottom sheet's height cap. Sets --uk-sheet-max-h on this sheet. |
width | string | 26rem | The side sheet's width from 640, never more than the screen less 2rem. Sets --uk-sheet-w on this sheet. |
closeLabel | string | 'סגירה' | The ×'s name for a screen reader. |
note | ReactNode | - | An sr-only description, read when the sheet opens (aria-describedby). UkFilters passes "כל שינוי חל מיד על התוצאות". |
describedBy | string | - | The id of a visible element in the body that describes the sheet; joins note in aria-describedby. |
live | ReactNode | - | 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. |
initialFocus | RefObject<HTMLElement | null> | the × | What takes focus on opening. The × by default, so no keyboard pops up on a phone. |
keepMounted | boolean | false | Keep the content mounted while closed, so a half-filled form survives a swipe. |
id | string | - | The dialog's id, for the trigger's aria-controls. |
className | string | - | More classes on the <dialog>: a kit's hook (uk-filters__sheet). |