Build the chat, goal, classified and calendar tab groups from one SegmentedButtons
This commit is contained in:
parent
78e866ad73
commit
34ee4fab5b
7 changed files with 94 additions and 123 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
41
src/lib/components/SegmentedButtons.svelte
Normal file
41
src/lib/components/SegmentedButtons.svelte
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue