Mobile

SplitView

Two or three panes with a divider to drag, which give way by width, with the selection kept across a turn and a keyboard route for the divider.

#mobile#tablet#interactive#layout#panes#resize

Demo

Preview

Notes

Folders

Inbox

Site visit 7

Notes from visit 7: the north gate was open, and the keys are with the caretaker until Friday.

Not resized yet

Files

Twelve files
Nothing chosen

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/split_view.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/split_view.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/split_view.qwe
let split_view_demo_css = css`.demo-split-head { position: sticky; top: 0; z-index: 1; margin: 0; padding: 12px 14px; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); font: 600 14px/1.3 var(--ui-font); }.demo-split-row { display: block; box-sizing: border-box; width: 100%; min-height: 56px; padding: 8px 14px; border: 0; border-bottom: 1px solid var(--ui-border); background: transparent; color: var(--ui-fg); font: 15px/1.3 var(--ui-font); text-align: left; cursor: pointer; }.demo-split-row small { display: block; color: var(--ui-muted); font-size: 13px; }.demo-split-row[aria-current="true"] { background: var(--ui-accent-soft); }.demo-split-body { padding: 16px; font: 15px/1.6 var(--ui-font); }.demo-split-body h4 { margin: 0 0 8px; font-size: 19px; }.demo-split-body textarea { box-sizing: border-box; width: 100%; min-height: 96px; margin-top: 12px; padding: 8px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 16px/1.4 var(--ui-font); }.demo-split-out { margin: 8px 0 0; }`let split_view_demo_lists = ["Inbox", "Sent", "Archive", "Drafts"]let split_view_demo_notes = range(1, 31) | map((n) => {    id: n,    title: `Site visit ${n}`,    who: n % 2 == 0 ? "Priya Nair" : "Tom Okafor",    body: `Notes from visit ${n}: the north gate was open, and the keys are with the caretaker until Friday.`})component SplitViewDemoLive() {    style global split_view_demo_css    state pane = 1    state folder = "Inbox"    state open = 7    state said = "Not resized yet"    state tall = "100%"    let note = find(split_view_demo_notes, (n) => n.id == open)    <div.demo-stack>        <div.demo-tablet>            <SplitView titles={["Folders", "Notes", "Reader"]} sizes={[22, 33]} pane={pane} height={tall} label="Field notes" on_pane={(i) => { pane = i }}                       on_resize={(s) => { said = `First ${s.first}, second ${s.second}, two-up ${s.medium}` }}>                <SplitViewPane title="Folders">                    <p.demo-split-head>Folders</p>                    {for f in split_view_demo_lists =>                        <button.demo-split-row key={f} type="button" data-folder={f} aria-current={f == folder ? "true" : null}                                @click={() => { folder = f; pane = 1 }}>{f}</button>}                </SplitViewPane>                <SplitViewPane title="Notes">                    <p.demo-split-head id="split-demo-folder">{folder}</p>                    {for n in split_view_demo_notes =>                        <button.demo-split-row key={n.id} type="button" data-note={n.id} aria-current={n.id == open ? "true" : null}                                @click={() => { open = n.id; pane = 2 }}>{n.title}<small>{n.who}</small></button>}                </SplitViewPane>                <SplitViewPane title="Reader">                    <div.demo-split-body>                        <h4 id="split-demo-title">{note.title}</h4>                        <p>{note.body}</p>                        <label for="split-demo-reply">Reply</label>                        <textarea id="split-demo-reply"></textarea>                    </div>                </SplitViewPane>            </SplitView>        </div>        <div.demo-row>            <Button id="split-demo-tall" size="lg" variant="outline" on_click={() => { tall = tall == "100%" ? "96%" : "100%" }}>Change the height</Button>            <p.demo-note.demo-split-out id="split-demo-said">{said}</p>        </div>        <div.demo-tablet style="height: 360px">            <SplitView titles={["Files", "Preview"]} sizes={[40]} folded="" label="Files">                <SplitViewPane title="Files"><div.demo-split-body id="split-demo-two-a">Twelve files</div></SplitViewPane>                <SplitViewPane title="Preview"><div.demo-split-body id="split-demo-two-b">Nothing chosen</div></SplitViewPane>            </SplitView>        </div>    </div>}view SplitViewDemo() = <SplitViewDemoLive/>

Source

ui/split_view.qwe
// SplitView: two or three panes side by side, as an iPad app lays them out, with a// divider a finger can drag. As the width shrinks the panes give way: three panes show// when it is 840 px or more, two from 600, one below that, with a bar to go between them.// Width means its own box, so a turn, Split View and Slide Over all count. The pane in// use stays in sight through every change, and panes out of sight stay on the page, so// a selection, a scroll and anything typed are as they were when they come back.////   import ui/split_view.qwe use SplitView, SplitViewPane////   <SplitView titles={["Folders", "Messages", "Reader"]} sizes={[24, 34]}//              pane={pane} on_pane={(i) => { pane = i }}>//       <SplitViewPane title="Folders"><Folders/></SplitViewPane>//       <SplitViewPane title="Messages"><Messages on_open={(m) => { open = m; pane = 2 }}/></SplitViewPane>//       <SplitViewPane title="Reader"><Reader message={open}/></SplitViewPane>//   </SplitView>//// titles: a title for each pane, two or three of them: that says how many there are.// children: as many SplitViewPanes, in order. Not one inside another's pane.// sizes: where the panes start, the first's share of the width in percent, and the second's// when there are three ([30, 35]); the last takes the rest. After that the divider is theirs. medium: the first pane's share when// two show out of three (40). min_width: the least a pane is dragged to, in px (200).// step: how far an arrow key moves a divider, in percent (4; with Shift, three times that).// pane: the pane in use, from 0: it stays in sight when room runs short. A tap or the// focus in a pane makes it the one in use, and the bar moves it too. Inside one of your// components, hold it in your state with on_pane to send a person on to a pane: on_pane(i)// is called with each change. folded: "first" or "last" starts with that pane put away.// on_resize({ first, second, medium }): inside one of your components, the sizes after a// drag or a key. bar: the bar with Back, Next and Hide (true). height: as CSS ("100%").// label: what the whole is called for screen readers.//// The divider: drag it, or focus it and use the arrow keys, Home and End. Enter puts the// pane beside it away and brings it back; so do dragging past the pane's least width, or// a flick towards it, and Hide in the bar. The divider of a pane put away waits at the// edge: a tap or Enter brings the pane back. One pane is away at a time. Screen readers// read it as a separator with its percentage. A vertical pull on it leaves the page to scroll.//// SplitViewPane(title): one pane, which scrolls by itself.import ui/theme.qwe use ui_clslet split_view_css = css`.ui-split-view {  position: relative; display: flex; flex-direction: column; box-sizing: border-box; min-width: 0; min-height: 0; height: var(--ui-split-view-h, 100%);  container: ui-split-view / inline-size; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.5 var(--ui-font);}.ui-split-view-bar {  display: flex; align-items: center; gap: 4px; flex: none; min-height: 52px; box-sizing: border-box; padding: 3px 6px;  border-bottom: 1px solid var(--ui-border); background: var(--ui-surface);}.ui-split-view-title { flex: 1; min-width: 0; margin: 0; padding: 0 8px; overflow: hidden; font-size: 15px; font-weight: 600; text-align: center; text-overflow: ellipsis; white-space: nowrap; }.ui-split-view-go {  display: inline-flex; align-items: center; gap: 4px; flex: none; min-width: 44px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border: 0;  border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 500 15px/1.2 var(--ui-font); cursor: pointer;  -webkit-tap-highlight-color: transparent;}.ui-split-view-go:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-split-view-go:hover { background: var(--ui-subtle); } }.ui-split-view-body { position: relative; flex: 1; min-height: 0; overflow: hidden; }.ui-split-view-panes { display: flex; width: 100%; height: 100%; }.ui-split-view-pane {  box-sizing: border-box; flex: 1 1 0; min-width: 0; height: 100%; overflow: auto; overflow-anchor: none; overscroll-behavior: contain; outline: none;  -webkit-overflow-scrolling: touch; border-left: 1px solid var(--ui-border);}.ui-split-view-pane:first-child { border-left: 0; }.ui-split-view-divider { position: absolute; top: 0; bottom: 0; z-index: 2; display: none; width: 44px; margin-left: -22px; touch-action: pan-y; cursor: col-resize; outline: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }.ui-split-view-grip { position: absolute; top: 50%; left: 50%; width: 8px; height: 56px; margin: -28px 0 0 -4px; border-radius: 999px; background: var(--ui-strong); transition: background .15s, transform .15s; }.ui-split-view-divider:focus-visible .ui-split-view-grip { background: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-split-view-dragging .ui-split-view-grip { background: var(--ui-accent); transform: scaleX(1.25); }.ui-split-view-dragging { cursor: col-resize; }.ui-split-view-dragging .ui-split-view-pane { pointer-events: none; }.ui-split-view-settle .ui-split-view-pane, .ui-split-view-settle .ui-split-view-divider { transition: flex-basis .22s cubic-bezier(.2, .8, .2, 1), left .22s cubic-bezier(.2, .8, .2, 1); }@container ui-split-view (min-width: 840px) {  .ui-split-view-panes > :nth-child(1) { flex: 0 0 var(--ui-split-view-a); }  .ui-split-view[data-count="3"] .ui-split-view-panes > :nth-child(2) { flex: 0 0 var(--ui-split-view-b); }  .ui-split-view[data-fold="last"] .ui-split-view-panes > :nth-child(2) { flex: 1 1 0; }  .ui-split-view[data-fold="first"] .ui-split-view-panes > :nth-child(1), .ui-split-view[data-fold="last"] .ui-split-view-panes > :nth-child(3) {    position: absolute; inset: 0 auto 0 0; width: 40%; visibility: hidden; pointer-events: none;  }  .ui-split-view-divider[data-index="0"] { display: block; left: var(--ui-split-view-a); }  .ui-split-view-divider[data-index="1"] { display: block; left: calc(var(--ui-split-view-a) + var(--ui-split-view-b)); }  /* A pane put away leaves its divider at the edge: the way back for a finger and for the keys. */  .ui-split-view[data-fold="first"] .ui-split-view-divider[data-index="0"] { left: 0; margin-left: 0; }  .ui-split-view[data-fold="first"] .ui-split-view-divider[data-index="1"] { left: var(--ui-split-view-b); }  .ui-split-view[data-fold="last"] .ui-split-view-divider[data-index="1"] { left: 100%; margin-left: -44px; }}@container ui-split-view (min-width: 600px) and (max-width: 839.98px) {  .ui-split-view[data-count="2"] .ui-split-view-panes > :nth-child(1) { flex: 0 0 var(--ui-split-view-a); }  .ui-split-view[data-count="2"][data-fold="first"] .ui-split-view-panes > :nth-child(1) { position: absolute; inset: 0 auto 0 0; width: 40%; visibility: hidden; pointer-events: none; }  .ui-split-view[data-count="2"] .ui-split-view-divider[data-index="0"] { display: block; left: var(--ui-split-view-a); }  .ui-split-view[data-count="2"][data-fold="first"] .ui-split-view-divider[data-index="0"] { left: 0; margin-left: 0; }  .ui-split-view[data-count="3"][data-at="0"] .ui-split-view-panes > :nth-child(1) { flex: 0 0 var(--ui-split-view-m); }  .ui-split-view[data-count="3"][data-at="0"] .ui-split-view-panes > :nth-child(3) { position: absolute; inset: 0 auto 0 0; width: 40%; visibility: hidden; pointer-events: none; }  .ui-split-view[data-count="3"]:not([data-at="0"]) .ui-split-view-panes > :nth-child(1) { position: absolute; inset: 0 auto 0 0; width: 40%; visibility: hidden; pointer-events: none; }  .ui-split-view[data-count="3"]:not([data-at="0"]) .ui-split-view-panes > :nth-child(2) { flex: 0 0 var(--ui-split-view-m); border-left: 0; }  .ui-split-view[data-count="3"][data-at="0"] .ui-split-view-divider[data-index="0"] { display: block; left: var(--ui-split-view-m); }  .ui-split-view[data-count="3"]:not([data-at="0"]) .ui-split-view-divider[data-index="1"] { display: block; left: var(--ui-split-view-m); }}@container ui-split-view (max-width: 599.98px) {  .ui-split-view-panes > .ui-split-view-pane { position: absolute; inset: 0 auto 0 0; width: 100%; visibility: hidden; pointer-events: none; border-left: 0; }  .ui-split-view[data-at="0"] .ui-split-view-panes > :nth-child(1), .ui-split-view[data-at="1"] .ui-split-view-panes > :nth-child(2),  .ui-split-view[data-at="2"] .ui-split-view-panes > :nth-child(3) { visibility: visible; pointer-events: auto; }}@media (prefers-reduced-motion: reduce) {  .ui-split-view-settle .ui-split-view-pane, .ui-split-view-settle .ui-split-view-divider, .ui-split-view-grip { transition: none; }}`fn split_view_clamp(v: num, lo: num, hi: num) -> num = hi < lo ? lo : v < lo ? lo : v > hi ? hi : v// The size a width gives. A width not measured yet is nothing's business: "".fn split_view_size(width: num) -> str = width <= 0 ? "" : width < 600 ? "compact" : width < 840 ? "medium" : "expanded"// How far along the panes a divider sits, in percent, for the pane sizes given.fn split_view_at(size: str, count: int, fold: str, i: int, s) -> num {    if size == "medium" { return count == 3 ? s.medium : s.first }    if i == 0 { return s.first }    fold == "first" ? s.second : s.first + s.second}// The range a divider may move in: `room` is the least a pane may be, in percent.fn split_view_range(size: str, count: int, fold: str, i: int, s, room: num) {    if size == "medium" || count == 2 { return { low: room, high: 100 - room } }    if i == 0 { return { low: room, high: s.first + s.second - room } }    fold == "first" ? { low: room, high: 100 - room } : { low: s.first + room, high: 100 - room }}// The sizes with a divider at `to`, which the caller has already kept in range. The// first divider gives what the first pane gains to the second, and leaves the third alone.fn split_view_put(size: str, count: int, fold: str, i: int, to: num, s) {    if size == "medium" && count == 3 { return { first: s.first, second: s.second, medium: to } }    if i == 0 {        let second = count == 3 ? s.first + s.second - to : s.second        return { first: to, second: second, medium: s.medium }    }    fold == "first" ? { first: s.first, second: to, medium: s.medium } : { first: s.first, second: to - s.first, medium: s.medium }}// What a drag ends in: it puts a pane away when the finger went well past the least// width, or flicked that way. Only the outer panes go. Gives "first", "last" or null.fn split_view_folds(i: int, count: int, size: str, past: num, speed: num, moved: num) -> str? {    let may_first = i == 0 && (size == "expanded" || count == 2)    let may_last = i == 1 && size == "expanded"    if may_first && (past < -48 || (speed < -0.6 && moved < -24)) { return "first" }    if may_last && (past > 48 || (speed > 0.6 && moved > 24)) { return "last" }    null}fn split_view_sizes(sizes, count: int, medium: num) {    let raw = type_of(sizes) == "list" ? sizes | filter((n) => type_of(n) == "int" || type_of(n) == "float") : []    let first = split_view_clamp(len(raw) > 0 ? raw[0] : (count == 3 ? 28 : 36), 5, 90)    let second = split_view_clamp(len(raw) > 1 ? raw[1] : 34, 5, 95 - first)    { first: first, second: second, medium: split_view_clamp(medium, 10, 90) }}fn split_view_vars(s, height: str?) -> str {    let tall = height != null && !re.test(height, "[;{}<>\"]") ? `; --ui-split-view-h: ${height}` : ""    `--ui-split-view-a: ${s.first}%; --ui-split-view-b: ${s.second}%; --ui-split-view-m: ${s.medium}%${tall}`}view SplitViewPane(title: str? = null, class: str? = null, children) {    style global split_view_css    <section class={ui_cls(["ui-split-view-pane", class])} aria-label={title} tabindex="-1">{children}</section>}component SplitView(titles: list = ["First", "Second"], sizes = null, medium: num = 40, min_width: num = 200, step: num = 4,                    pane: int = 0, folded: str = "", bar: bool = true, height: str? = null, label: str? = null,                    on_pane = null, on_resize = null, class: str? = null, children) {    style global split_view_css    let count = len(titles) >= 3 ? 3 : 2    state at = split_view_clamp(pane, 0, len(titles) >= 3 ? 2 : 1)    state seen = pane    state fold = folded == "first" || folded == "last" ? folded : ""    state shape = split_view_sizes(sizes, len(titles) >= 3 ? 3 : 2, medium)    state size = ""    state root = null    state pull = null          // { i, x, last_x, last_at, speed, on, shape, past } while a finger is on a divider    if pane != seen {        seen = pane        at = split_view_clamp(pane, 0, count - 1)    }    // The live sizes go on the body, which has no style in its markup: a style={...} is    // written whole whenever what it reads changes, which would take them away mid-drag.    let body = () => root.querySelector(".ui-split-view-body")    let show = (s) => {        if root != null {            let b = body()            b.style.setProperty("--ui-split-view-a", `${s.first}%`)            b.style.setProperty("--ui-split-view-b", `${s.second}%`)            b.style.setProperty("--ui-split-view-m", `${s.medium}%`)        }    }    let go = (i: int) => {        if i != at {            at = i            on_pane && on_pane(i)        }    }    let settle = () => {        if root != null {            let b = body()            b.classList.add("ui-split-view-settle")            b.addEventListener("transitionend", (_e) => { b.classList.remove("ui-split-view-settle") }, { once: true })        }    }    let told = () => { on_resize && on_resize({ first: round(shape.first, 1), second: round(shape.second, 1), medium: round(shape.medium, 1) }) }    let room = () => {        let w = root != null ? root.clientWidth : 1000        split_view_clamp(min_width / w * 100, 5, 45)    }    // Where a press on a pane or the focus in one lands: which pane is in use.    let used = (e) => {        let p = e.target.closest != null ? e.target.closest(".ui-split-view-pane") : null        if p != null && p.parentElement != null {            let kids = p.parentElement.children            // isSameNode and not ==, which calls any two elements equal.            let found = range(kids.length) | filter((n) => kids[n].isSameNode(p))            if len(found) > 0 { go(found[0]) }        }    }    let away = (i: int) => (fold == "first" && i == 0) || (fold == "last" && i == 1)    let flip = (which: str) => {        fold = fold == which ? "" : which        if fold == "first" && at == 0 { go(1) }        if fold == "last" && at == 2 { go(1) }    }    let back = at == 0 || size == "" || size == "expanded" || (size == "medium" && count == 2) ? null : (size == "medium" ? 0 : at - 1)    let next = size == "compact" && at < count - 1 ? at + 1 : (size == "medium" && count == 3 && at == 0 ? 2 : null)    let can_hide = size == "expanded" || (size == "medium" && count == 2)    <div class={ui_cls(["ui-split-view", class])} role="group" aria-label={label} data-count={count} data-at={at}         data-fold={fold == "" ? null : fold} data-size={size == "" ? null : size} style={split_view_vars(split_view_sizes(sizes, count, medium), height)}         @mount={(el) => {             let look = (_e = null) => { size = split_view_size(el.clientWidth) }             let seen_by = js_new("ResizeObserver", look)             seen_by.observe(el)             root = el             () => { seen_by.disconnect() }         }}>        {bar && <div.ui-split-view-bar>            {back != null && <button.ui-split-view-go type="button" data-go="back" @click={() => { go(back) }}><span aria-hidden="true">‹</span> {titles[back]}</button>}            {can_hide && <button.ui-split-view-go type="button" data-go="hide" aria-expanded={fold == "first" ? "false" : "true"}                                 @click={() => { flip("first"); told() }}>{fold == "first" ? `Show ${titles[0]}` : `Hide ${titles[0]}`}</button>}            <p.ui-split-view-title aria-live="polite">{titles[at]}</p>            {next != null && <button.ui-split-view-go type="button" data-go="next" @click={() => { go(next) }}>{titles[next]} <span aria-hidden="true">›</span></button>}        </div>}        <div.ui-split-view-body>            <div.ui-split-view-panes @pointerdown={used} @focusin={used}>{children}</div>            {for i in range(count - 1) =>                <div.ui-split-view-divider key={i} data-index={i} role="separator" tabindex="0" aria-orientation="vertical"                     aria-label={`Resize ${titles[i]} and ${titles[i + 1]}`}                     aria-valuenow={away(i) ? (i == 0 ? 0 : 100) : round(split_view_at(size, count, fold, i, shape))} aria-valuemin={0} aria-valuemax={100}                     @pointerdown={(e) => {                         if e.button == 0 {                             e.preventDefault()                             e.target.setPointerCapture(e.pointerId)                             e.target.focus()                             pull = { i: i, x: e.clientX, last_x: e.clientX, last_at: e.timeStamp, speed: 0, on: false, shape: shape, past: 0 }                         }                     }}                     @pointermove={(e) => {                         if pull != null {                             let dx = e.clientX - pull.x                             let on = pull.on || abs(dx) > 4                             let dt = e.timeStamp - pull.last_at                             let step_v = dt > 0 ? (e.clientX - pull.last_x) / dt : pull.speed                             let past = pull.past                             let live = pull.shape                             if on && !away(pull.i) {                                 let r = body().getBoundingClientRect()                                 let raw = (e.clientX - r.left) / r.width * 100                                 let lim = split_view_range(size, count, fold, pull.i, pull.shape, room())                                 let kept = split_view_clamp(raw, lim.low, lim.high)                                 past = (raw - kept) / 100 * r.width                                 // Past the limit it gives a little, to say there is more to a harder pull.                                 live = split_view_put(size, count, fold, pull.i, kept, pull.shape)                                 show(split_view_put(size, count, fold, pull.i, kept + (raw - kept) * 0.2, pull.shape))                                 body().classList.add("ui-split-view-dragging")                             }                             pull = { i: pull.i, x: pull.x, last_x: e.clientX, last_at: e.timeStamp, speed: pull.speed == 0 ? step_v : pull.speed * 0.4 + step_v * 0.6,                                      on: on, shape: pull.shape, past: past, live: live }                         }                     }}                     @pointerup={(e) => {                         if pull != null {                             let was = pull                             pull = null                             body().classList.remove("ui-split-view-dragging")                             if away(was.i) {                                 settle()                                 flip(was.i == 0 ? "first" : "last")                                 told()                             } else if was.on {                                 let run = e.timeStamp - was.last_at < 100 ? was.speed : 0                                 let drop = split_view_folds(was.i, count, size, was.past, run, e.clientX - was.x)                                 settle()                                 if drop != null {                                     show(was.shape)                                     flip(drop)                                 } else {                                     shape = was.live                                     show(was.live)                                 }                                 told()                             }                         }                     }}                     @pointercancel={() => {                         if pull != null {                             let was = pull                             pull = null                             body().classList.remove("ui-split-view-dragging")                             settle()                             show(was.shape)                         }                     }}                     @keydown={(e) => {                         let now = split_view_at(size, count, fold, i, shape)                         let lim = split_view_range(size, count, fold, i, shape, room())                         let by = e.shiftKey ? step * 3 : step                         let to = match e.key {                             "ArrowLeft" => now - by                             "ArrowRight" => now + by                             "Home" => lim.low                             "End" => lim.high                             _ => null                         }                         if to != null && !away(i) {                             e.preventDefault()                             shape = split_view_put(size, count, fold, i, split_view_clamp(to, lim.low, lim.high), shape)                             show(shape)                             told()                         } else if e.key == "Enter" {                             e.preventDefault()                             flip(i == 0 ? "first" : "last")                             told()                         }                     }}>                    <span.ui-split-view-grip aria-hidden="true"></span>                </div>}        </div>    </div>}
Thread
37Useful? Like it, or add to its thread below.

Thread 0 posts

On SplitView: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.