254 lines
6.7 KiB
Swift
254 lines
6.7 KiB
Swift
import Elementary
|
|
|
|
/// The site header: the wordmark, the format navigation, the search field, and the viewer's actions.
|
|
///
|
|
/// One markup serves all three layouts; the shared stylesheet rearranges it through grid areas.
|
|
struct SiteHeader: HTML {
|
|
|
|
// MARK: Properties
|
|
|
|
/// The format marked as current in the navigation, or `nil` for none.
|
|
let active: Format?
|
|
|
|
/// The signed-in viewer, or `nil` to render the signed-out actions.
|
|
let viewer: Viewer?
|
|
|
|
// MARK: Initializers
|
|
|
|
/// Creates a site header.
|
|
/// - Parameters:
|
|
/// - active: the format marked as current, or `nil` (the default) for none.
|
|
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out actions.
|
|
init(
|
|
active: Format? = nil,
|
|
viewer: Viewer? = nil
|
|
) {
|
|
self.active = active
|
|
self.viewer = viewer
|
|
}
|
|
|
|
// MARK: Computed
|
|
|
|
var body: some HTML {
|
|
header(.class("site-header")) {
|
|
div(.class("site-header__inner")) {
|
|
a(
|
|
.class("site-header__brand"),
|
|
.href(.Route.home)
|
|
) {
|
|
"CCN"
|
|
}
|
|
navigation
|
|
search
|
|
div(.class("site-header__actions")) {
|
|
if let viewer {
|
|
actions(for: viewer)
|
|
} else {
|
|
guestActions
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private extension SiteHeader {
|
|
|
|
// MARK: Computed
|
|
|
|
/// The signed-out actions: *Open a shop* and the passkey sign-in.
|
|
@HTMLBuilder
|
|
var guestActions: some HTML {
|
|
a(
|
|
.class("site-header__icon-link phone-only"),
|
|
.href(.Route.openShop),
|
|
.custom(
|
|
name: "aria-label",
|
|
value: "Open a shop"
|
|
)
|
|
) {
|
|
Icon(.shop, size: 22)
|
|
}
|
|
a(
|
|
.class("site-header__text-link from-desktop"),
|
|
.href(.Route.openShop)
|
|
) {
|
|
"Open a shop"
|
|
}
|
|
a(
|
|
.class("site-header__sign-in"),
|
|
.href(.Route.signIn)
|
|
) {
|
|
Icon(.key, size: 15)
|
|
span {
|
|
"Sign in"
|
|
span(.class("from-desktop")) {
|
|
" with passkey"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The format links, plus the series and events links shown on tablet only.
|
|
var navigation: some HTML {
|
|
nav(
|
|
.class("site-nav"),
|
|
.custom(
|
|
name: "aria-label",
|
|
value: "Formats"
|
|
)
|
|
) {
|
|
for format in Format.allCases {
|
|
if format == active {
|
|
a(
|
|
.class("site-nav__link"),
|
|
.href(format.path),
|
|
.custom(
|
|
name: "aria-current",
|
|
value: "page"
|
|
)
|
|
) {
|
|
format.label
|
|
}
|
|
} else {
|
|
a(
|
|
.class("site-nav__link"),
|
|
.href(format.path)
|
|
) {
|
|
format.label
|
|
}
|
|
}
|
|
}
|
|
a(
|
|
.class("site-nav__link site-nav__link--extra"),
|
|
.href(.Route.series)
|
|
) {
|
|
"Series"
|
|
}
|
|
a(
|
|
.class("site-nav__link site-nav__link--extra"),
|
|
.href(.Route.events)
|
|
) {
|
|
"Events"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The search field, a plain `GET` form.
|
|
var search: some HTML {
|
|
form(
|
|
.class("site-search"),
|
|
.action(.Route.search),
|
|
.method(.get),
|
|
.custom(
|
|
name: "role",
|
|
value: "search"
|
|
)
|
|
) {
|
|
label(
|
|
.class("visually-hidden"),
|
|
.for("site-search-query")
|
|
) {
|
|
"Search listings"
|
|
}
|
|
input(
|
|
.class("site-search__input"),
|
|
.id("site-search-query"),
|
|
.type(.search),
|
|
.name("q"),
|
|
.placeholder("Search title, issue, grade or shop")
|
|
)
|
|
button(
|
|
.class("site-search__submit"),
|
|
.type(.submit),
|
|
.custom(
|
|
name: "aria-label",
|
|
value: "Search"
|
|
)
|
|
) {
|
|
Icon(.search, size: 16)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Methods
|
|
|
|
/// The signed-in actions: want list, messages, shop, and account.
|
|
/// - Parameter viewer: the signed-in viewer.
|
|
@HTMLBuilder
|
|
func actions(
|
|
for viewer: Viewer
|
|
) -> some HTML {
|
|
counter(
|
|
.bell,
|
|
label: "Want list",
|
|
count: viewer.newWantMatches,
|
|
unit: "new matches",
|
|
path: .Route.wants
|
|
)
|
|
counter(
|
|
.message,
|
|
label: "Messages",
|
|
count: viewer.unreadMessages,
|
|
unit: "unread",
|
|
path: .Route.messages
|
|
)
|
|
a(
|
|
.class("site-header__text-link site-header__text-link--strong from-desktop"),
|
|
.href(.Route.sellerDashboard)
|
|
) {
|
|
"My shop"
|
|
}
|
|
a(
|
|
.class("site-header__account"),
|
|
.href(.Route.account),
|
|
.custom(
|
|
name: "aria-label",
|
|
value: "Account of \(viewer.displayName)"
|
|
)
|
|
) {
|
|
span(.class("from-desktop")) {
|
|
viewer.displayName
|
|
}
|
|
span(.class("site-header__avatar")) {
|
|
viewer.initials
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An icon link with a count badge, omitted at zero.
|
|
/// - Parameters:
|
|
/// - glyph: the glyph the link shows.
|
|
/// - label: the link's accessible name.
|
|
/// - count: the number the badge shows.
|
|
/// - unit: what the count measures.
|
|
/// - path: the path the link leads to.
|
|
func counter(
|
|
_ glyph: Icon.Glyph,
|
|
label: String,
|
|
count: Int,
|
|
unit: String,
|
|
path: String
|
|
) -> some HTML {
|
|
a(
|
|
.class("site-header__icon-link"),
|
|
.href(path),
|
|
.custom(
|
|
name: "aria-label",
|
|
value: count > 0 ? "\(label), \(count) \(unit)" : label
|
|
)
|
|
) {
|
|
Icon(glyph)
|
|
if count > 0 {
|
|
span(.class("site-header__badge")) {
|
|
"\(count)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|