Build the chat, goal, classified and calendar tab groups from one SegmentedButtons

This commit is contained in:
Jon Staab 2026-09-25 22:14:33 -07:00
parent 78e866ad73
commit 34ee4fab5b
7 changed files with 94 additions and 123 deletions

View file

@ -221,7 +221,7 @@ lives there:
- modal chrome: `Dialog`, `Drawer`, `Modal`, `ModalBody`, `ModalHeader`, `ModalTitle`,
`ModalSubtitle`, `ModalFooter`, `Confirm`
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`,
`Popover`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens

View file

@ -1,10 +1,9 @@
<script lang="ts">
import cx from "classnames"
import Button from "@lib/components/Button.svelte"
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
import {CHAT_TABS, ChatTab} from "@app/chats"
import type {ChatsByTab} from "@app/chats"
interface Props {
type Props = {
tab: ChatTab
chatsByTab: ChatsByTab
class?: string
@ -12,22 +11,9 @@
let {tab = $bindable(), chatsByTab, ...props}: Props = $props()
const setTab = (value: ChatTab) => () => {
tab = value
}
const options = $derived(
CHAT_TABS.map(({value, label}) => ({value, label, count: chatsByTab[value].length})),
)
</script>
<div class={cx("flex items-center gap-2", props.class)}>
{#each CHAT_TABS as { value, label } (value)}
<Button
aria-pressed={tab === value}
class={cx(
"button button-sm shrink-0 rounded-full",
tab === value ? "button-primary" : "button-neutral",
)}
onclick={setTab(value)}>
{label}
<span class="opacity-75">{chatsByTab[value].length}</span>
</Button>
{/each}
</div>
<SegmentedButtons {options} bind:value={tab} class={props.class} />

View file

@ -0,0 +1,41 @@
<script lang="ts" module>
export type SegmentedOption<T> = {value: T; label: string; count?: number}
</script>
<script lang="ts" generics="T">
import cx from "classnames"
import Button from "@lib/components/Button.svelte"
type Props = {
options: SegmentedOption<T>[]
value: T
joined?: boolean
size?: "xs" | "sm"
class?: string
}
let {options, value = $bindable(), joined, size = "sm", ...props}: Props = $props()
const select = (option: T) => () => {
value = option
}
</script>
<div class={cx(joined ? "join" : "flex items-center gap-2", props.class)}>
{#each options as option (option.value)}
<Button
aria-pressed={value === option.value}
class={cx(
"button",
{"button-xs": size === "xs", "button-sm": size === "sm"},
joined ? "join-item grow" : "shrink-0 rounded-full",
value === option.value ? "button-primary" : "button-neutral",
)}
onclick={select(option.value)}>
{option.label}
{#if option.count !== undefined}
<span class="opacity-75">{option.count}</span>
{/if}
</Button>
{/each}
</div>

View file

@ -1,6 +1,5 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte"
import cx from "classnames"
import type {Readable} from "svelte/store"
import {readable} from "svelte/store"
import type {Maybe} from "@welshman/lib"
@ -13,6 +12,8 @@
import {fade} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
import type {SegmentedOption} from "@lib/components/SegmentedButtons.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import CalendarAgenda from "@app/components/CalendarAgenda.svelte"
@ -45,8 +46,6 @@
const makeEvent = () => pushModal(CalendarEventCreate, {url})
const showView = (target: CalendarView) => () => calendarView.set(target)
const showToday = () => {
cursor = new Date()
}
@ -85,13 +84,6 @@
}
}
const viewClass = (target: CalendarView) =>
cx(
"button join-item",
isNarrow ? "button-xs" : "button-sm",
view === target ? "button-primary" : "button-neutral",
)
let element: HTMLElement | undefined = $state()
onDestroy(context.cleanup)
@ -111,6 +103,19 @@
const isNarrow = $derived(width > 0 && width < 768)
const view = $derived(isNarrow && $calendarView === "month" ? "week" : $calendarView)
const viewOptions: SegmentedOption<CalendarView>[] = $derived(
isNarrow
? [
{value: "agenda", label: "Agenda"},
{value: "week", label: "Week"},
]
: [
{value: "agenda", label: "Agenda"},
{value: "week", label: "Week"},
{value: "month", label: "Month"},
],
)
const isCurrentRange = $derived(view === "month" ? isCurrentMonth(cursor) : isCurrentWeek(cursor))
const rangeLabel = $derived(view === "month" ? formatMonth(cursor) : formatWeekRange(cursor))
@ -170,25 +175,11 @@
<strong>Calendar</strong>
{/snippet}
{#snippet action()}
<div class="join">
<Button
class={viewClass("agenda")}
aria-pressed={view === "agenda"}
onclick={showView("agenda")}>
Agenda
</Button>
<Button class={viewClass("week")} aria-pressed={view === "week"} onclick={showView("week")}>
Week
</Button>
{#if !isNarrow}
<Button
class={viewClass("month")}
aria-pressed={view === "month"}
onclick={showView("month")}>
Month
</Button>
{/if}
</div>
<SegmentedButtons
joined
size={isNarrow ? "xs" : "sm"}
options={viewOptions}
bind:value={() => view, calendarView.set} />
<Button class="button button-primary button-sm" onclick={makeEvent}>
<Icon icon={Add} />
Create

View file

@ -4,7 +4,6 @@
<script lang="ts">
import {onDestroy} from "svelte"
import cx from "classnames"
import {derived, readable} from "svelte/store"
import {sleep} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
@ -12,7 +11,8 @@
import {TimeEvent} from "@welshman/domain"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
import type {SegmentedOption} from "@lib/components/SegmentedButtons.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import Content from "@app/components/Content.svelte"
import ContentActions from "@app/components/ContentActions.svelte"
@ -40,16 +40,18 @@
const comments = $derived($events.asc(filters).$)
const rsvps = $derived($event ? deriveRsvps($event) : readable<TrustedEvent[]>([]))
const people = $derived(getRsvpsByStatus($rsvps))
const tabs: SegmentedOption<EventTab>[] = $derived([
{value: "about", label: "About"},
{value: "discussion", label: "Discussion", count: $comments.length || undefined},
{value: "people", label: "People", count: people.latest.length || undefined},
])
const back = () => history.back()
const showTab = (target: EventTab) => () => {
tab = target
const showPeople = () => {
tab = "people"
}
const tabClass = (target: EventTab) =>
cx("button button-sm join-item grow", tab === target ? "button-primary" : "button-neutral")
onDestroy(context.cleanup)
let tab = $state<EventTab>("about")
@ -92,7 +94,7 @@
<div class="flex min-w-0 grow flex-col gap-2">
<CalendarEventHeader event={$event} />
<CalendarEventMeta event={$event} {url} />
<CalendarRsvp {url} event={$event} rsvps={$rsvps} onShowPeople={showTab("people")} />
<CalendarRsvp {url} event={$event} rsvps={$rsvps} onShowPeople={showPeople} />
<ContentActions
showRoom
noun="Event"
@ -104,29 +106,11 @@
</div>
<!-- One event carries three unrelated conversations, so give each its own tab instead of
stacking them all down the page -->
<div class="join w-full sm:w-auto sm:self-start">
<Button class={tabClass("about")} aria-pressed={tab === "about"} onclick={showTab("about")}>
About
</Button>
<Button
class={tabClass("discussion")}
aria-pressed={tab === "discussion"}
onclick={showTab("discussion")}>
Discussion
{#if $comments.length > 0}
<span class="opacity-75">{$comments.length}</span>
{/if}
</Button>
<Button
class={tabClass("people")}
aria-pressed={tab === "people"}
onclick={showTab("people")}>
People
{#if people.latest.length > 0}
<span class="opacity-75">{people.latest.length}</span>
{/if}
</Button>
</div>
<SegmentedButtons
joined
options={tabs}
bind:value={tab}
class="w-full sm:w-auto sm:self-start" />
{#if tab === "about"}
<div class="card z-feature flex flex-col gap-3">
{#if $event.content.trim()}

View file

@ -16,6 +16,7 @@
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
@ -51,10 +52,6 @@
goto(path, {replaceState: true, noScroll: true})
}
const setTab = (value: string) => () => {
tab = value
}
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
const loading = $derived(isFeedLoading($older))
@ -76,17 +73,13 @@
listings.filter(event => matchesQuery(event, query) && matchesTopic(event, topic)),
)
const counts = $derived.by(() => {
const result: Record<string, number> = {all: scoped.length, active: 0, sold: 0}
for (const event of scoped) {
const status = getStatus(event)
result[status] = (result[status] ?? 0) + 1
}
return result
})
const tabs = $derived(
CLASSIFIED_STATUS_TABS.map(({value, label}) => ({
value,
label,
count: scoped.filter(event => value === "all" || getStatus(event) === value).length,
})),
)
const items = $derived.by(() => {
const matches = scoped.filter(event => tab === "all" || getStatus(event) === tab)
@ -157,18 +150,7 @@
<!-- Scrolls on a phone, where wrapping this many chips would bury the listings. -->
<div
class="flex shrink-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible sm:pb-0">
{#each CLASSIFIED_STATUS_TABS as { value, label } (value)}
<Button
aria-pressed={tab === value}
class={cx(
"button button-sm shrink-0 rounded-full",
tab === value ? "button-primary" : "button-neutral",
)}
onclick={setTab(value)}>
{label}
<span class="opacity-75">{counts[value] ?? 0}</span>
</Button>
{/each}
<SegmentedButtons options={tabs} bind:value={tab} class="shrink-0" />
{#if topicCounts.length > 0}
<span class="mx-1 h-6 w-px shrink-0 bg-line"></span>
{#each topicCounts as [value, count] (value)}

View file

@ -2,7 +2,6 @@
import {onDestroy, onMount} from "svelte"
import {derived, readable} from "svelte/store"
import type {Readable} from "svelte/store"
import cx from "classnames"
import {equals, now, pluck, sortBy, spec} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
@ -15,6 +14,7 @@
import UsersGroupRounded from "@assets/icons/users-group-rounded.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import SegmentedButtons from "@lib/components/SegmentedButtons.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
@ -195,20 +195,7 @@
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap gap-2">
{#each tabs as { value, label } (value)}
{@const onClick = () => (tab = value)}
<Button
aria-pressed={tab === value}
class={cx(
"button button-sm rounded-full",
tab === value ? "button-primary" : "button-neutral",
)}
onclick={onClick}>
{label}
</Button>
{/each}
</div>
<SegmentedButtons options={tabs} bind:value={tab} class="flex-wrap" />
<select class="select input input-sm w-auto" bind:value={sort}>
<option value="active">Recently active</option>
<option value="new">Newest</option>