@talelk/newui-organisms 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +12 -2
- package/package.json +38 -3
- package/src/exports.test.ts +27 -0
- package/src/index.ts +1 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
- package/src/organisms/AppSidebar/index.ts +1 -0
- package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
- package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
- package/src/organisms/AuthCard/AuthCard.tsx +89 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
- package/src/organisms/CommandPalette/index.ts +1 -0
- package/src/organisms/DataState/DataState.test.tsx +45 -0
- package/src/organisms/DataState/DataState.tsx +111 -0
- package/src/organisms/DataState/index.ts +6 -0
- package/src/organisms/DataTable/DataTable.test.tsx +161 -0
- package/src/organisms/DataTable/DataTable.tsx +612 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
- package/src/organisms/FilterBar/FilterBar.tsx +251 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
- package/src/organisms/KanbanBoard/drag.test.ts +448 -0
- package/src/organisms/KanbanBoard/drag.ts +407 -0
- package/src/organisms/KanbanBoard/index.ts +17 -0
- package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
- package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
- package/src/organisms/MobileNav/MobileNav.tsx +120 -0
- package/src/organisms/MobileNav/index.ts +6 -0
- package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
- package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
- package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
- package/src/organisms/PageToolbar/index.ts +1 -0
- package/src/organisms/Section/Section.test.tsx +41 -0
- package/src/organisms/Section/Section.tsx +108 -0
- package/src/organisms/Section/index.ts +1 -0
- package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
- package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
- package/src/organisms/SelectionBar/index.ts +5 -0
- package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
- package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
- package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
- package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
- package/src/organisms/SettingsDialog/index.ts +20 -0
- package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
- package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
- package/src/organisms/Sidebar/Sidebar.tsx +732 -0
- package/src/organisms/Sidebar/index.ts +22 -0
- package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
- package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
- package/src/organisms/StatGrid/StatGrid.tsx +151 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +113 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- package/src/organisms/index.ts +16 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Badge, Button, type ButtonTheme } from '@talelk/newui-atoms'
|
|
2
|
+
import { type BreadcrumbItem, Breadcrumbs } from '@talelk/newui-molecules'
|
|
3
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
4
|
+
import { Dynamic } from 'solid-js/web'
|
|
5
|
+
|
|
6
|
+
export interface DetailAction {
|
|
7
|
+
label: string
|
|
8
|
+
onClick?: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DetailHeaderProps {
|
|
12
|
+
/** The trail above the title. Omitted → no breadcrumbs. */
|
|
13
|
+
breadcrumbs?: BreadcrumbItem[]
|
|
14
|
+
title: JSX.Element
|
|
15
|
+
/** Semantic level of the title. @default 1 */
|
|
16
|
+
headingLevel?: 1 | 2 | 3 | 4
|
|
17
|
+
/** Muted line under the title — a record id, an owner, a timestamp. */
|
|
18
|
+
subtitle?: JSX.Element
|
|
19
|
+
/** Status pill beside the title. */
|
|
20
|
+
status?: { label: string; theme?: ButtonTheme }
|
|
21
|
+
/**
|
|
22
|
+
* Trailing actions in order. Everything but the last renders as an outline
|
|
23
|
+
* button; the last is the primary one.
|
|
24
|
+
*/
|
|
25
|
+
actions?: DetailAction[]
|
|
26
|
+
/** Anything else on the trailing edge, before the actions. */
|
|
27
|
+
children?: JSX.Element
|
|
28
|
+
class?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The top of a record page: where it sits, what it is, what state it is in, and
|
|
33
|
+
* what you can do to it.
|
|
34
|
+
*
|
|
35
|
+
* The order is the point. A reader arrives from a list and needs three questions
|
|
36
|
+
* answered in that order — the trail first, so they know this is the record they
|
|
37
|
+
* clicked; then the name; then the status, because a status only means something
|
|
38
|
+
* once you know what it belongs to. Actions sit on the trailing edge with the
|
|
39
|
+
* primary one last, so the least-common (often destructive) action is never the
|
|
40
|
+
* one nearest the title.
|
|
41
|
+
*/
|
|
42
|
+
export function DetailHeader(props: DetailHeaderProps): JSX.Element {
|
|
43
|
+
const secondary = () => (props.actions ?? []).slice(0, -1)
|
|
44
|
+
const primary = () => (props.actions ?? []).at(-1)
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<header class={`flex flex-col gap-field-gap ${props.class ?? ''}`}>
|
|
48
|
+
<Show when={props.breadcrumbs}>
|
|
49
|
+
{(crumbs) => <Breadcrumbs items={crumbs()} />}
|
|
50
|
+
</Show>
|
|
51
|
+
|
|
52
|
+
<div class="flex flex-wrap items-start justify-between gap-card-gap">
|
|
53
|
+
<div class="flex min-w-0 flex-col gap-1">
|
|
54
|
+
<div class="flex min-w-0 items-center gap-control-gap">
|
|
55
|
+
<Dynamic
|
|
56
|
+
component={`h${props.headingLevel ?? 1}`}
|
|
57
|
+
class="min-w-0 text-metric text-ink-gray-9 [overflow-wrap:anywhere]"
|
|
58
|
+
>
|
|
59
|
+
{props.title}
|
|
60
|
+
</Dynamic>
|
|
61
|
+
<Show when={props.status}>
|
|
62
|
+
{(status) => (
|
|
63
|
+
<Badge
|
|
64
|
+
theme={status().theme ?? 'gray'}
|
|
65
|
+
label={status().label}
|
|
66
|
+
/>
|
|
67
|
+
)}
|
|
68
|
+
</Show>
|
|
69
|
+
</div>
|
|
70
|
+
<Show when={props.subtitle}>
|
|
71
|
+
<p class="truncate text-base text-ink-gray-7">{props.subtitle}</p>
|
|
72
|
+
</Show>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="flex shrink-0 flex-wrap items-center gap-control-gap">
|
|
76
|
+
{props.children}
|
|
77
|
+
<For each={secondary()}>
|
|
78
|
+
{(action) => (
|
|
79
|
+
<Button variant="outline" onClick={action.onClick}>
|
|
80
|
+
{action.label}
|
|
81
|
+
</Button>
|
|
82
|
+
)}
|
|
83
|
+
</For>
|
|
84
|
+
<Show when={primary()}>
|
|
85
|
+
{(action) => (
|
|
86
|
+
<Button variant="solid" onClick={action().onClick}>
|
|
87
|
+
{action().label}
|
|
88
|
+
</Button>
|
|
89
|
+
)}
|
|
90
|
+
</Show>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</header>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './DetailHeader'
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { FilterBar } from './FilterBar'
|
|
5
|
+
|
|
6
|
+
describe('FilterBar', () => {
|
|
7
|
+
it('renders a labelled search box', () => {
|
|
8
|
+
render(() => <FilterBar query={{ placeholder: 'Search files' }} />)
|
|
9
|
+
expect(screen.getByLabelText('Search files')).toBeTruthy()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('reports typing', async () => {
|
|
13
|
+
const onInput = vi.fn()
|
|
14
|
+
render(() => <FilterBar query={{ placeholder: 'Search', onInput }} />)
|
|
15
|
+
await userEvent.type(screen.getByLabelText('Search'), 'inv')
|
|
16
|
+
expect(onInput).toHaveBeenLastCalledWith('inv')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('gives every select an accessible name from its label', () => {
|
|
20
|
+
render(() => (
|
|
21
|
+
<FilterBar
|
|
22
|
+
selects={[
|
|
23
|
+
{
|
|
24
|
+
label: 'Owner',
|
|
25
|
+
options: [{ label: 'Anyone', value: 'any' }],
|
|
26
|
+
},
|
|
27
|
+
]}
|
|
28
|
+
/>
|
|
29
|
+
))
|
|
30
|
+
expect(screen.getByLabelText('Owner')).toBeTruthy()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('reports a select change', async () => {
|
|
34
|
+
const onValueChange = vi.fn()
|
|
35
|
+
render(() => (
|
|
36
|
+
<FilterBar
|
|
37
|
+
selects={[
|
|
38
|
+
{
|
|
39
|
+
label: 'Owner',
|
|
40
|
+
value: 'any',
|
|
41
|
+
onValueChange,
|
|
42
|
+
options: [
|
|
43
|
+
{ label: 'Anyone', value: 'any' },
|
|
44
|
+
{ label: 'Me', value: 'me' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
]}
|
|
48
|
+
/>
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
await userEvent.click(screen.getByLabelText('Owner'))
|
|
52
|
+
await userEvent.click(await screen.findByRole('option', { name: 'Me' }))
|
|
53
|
+
expect(onValueChange).toHaveBeenCalledWith('me')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('renders no view switcher unless one is given', () => {
|
|
57
|
+
const { container } = render(() => <FilterBar />)
|
|
58
|
+
expect(container.querySelector('[data-slot="tab-trigger"]')).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('puts the controls on one surface', () => {
|
|
62
|
+
const { container } = render(() => (
|
|
63
|
+
<FilterBar query={{ placeholder: 'Search' }} />
|
|
64
|
+
))
|
|
65
|
+
const bar = container.querySelector('[data-slot="filter-bar"]')
|
|
66
|
+
expect(bar?.className).toContain('bg-surface-elevation-1')
|
|
67
|
+
expect(bar?.className).toContain('border-outline-gray-1')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('focuses the search box on / and ignores / while editing elsewhere', async () => {
|
|
71
|
+
render(() => (
|
|
72
|
+
<>
|
|
73
|
+
<input aria-label="Other field" />
|
|
74
|
+
<FilterBar query={{ placeholder: 'Search tickets' }} />
|
|
75
|
+
</>
|
|
76
|
+
))
|
|
77
|
+
const search = screen.getByLabelText('Search tickets')
|
|
78
|
+
await userEvent.keyboard('/')
|
|
79
|
+
expect(document.activeElement).toBe(search)
|
|
80
|
+
|
|
81
|
+
const other = screen.getByLabelText('Other field')
|
|
82
|
+
other.focus()
|
|
83
|
+
await userEvent.keyboard('/')
|
|
84
|
+
expect(document.activeElement).toBe(other)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('states the result count, singular and plural', () => {
|
|
88
|
+
const one = render(() => <FilterBar resultCount={1} />)
|
|
89
|
+
expect(screen.getByText('1 result')).toBeTruthy()
|
|
90
|
+
one.unmount()
|
|
91
|
+
render(() => <FilterBar resultCount={24} />)
|
|
92
|
+
expect(screen.getByText('24 results')).toBeTruthy()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('renders chips and removes one by its labelled button', async () => {
|
|
96
|
+
const onRemove = vi.fn()
|
|
97
|
+
render(() => <FilterBar chips={[{ label: 'Status: Open', onRemove }]} />)
|
|
98
|
+
expect(screen.getByText(/Status: Open/)).toBeTruthy()
|
|
99
|
+
await userEvent.click(
|
|
100
|
+
screen.getByRole('button', { name: 'Remove filter: Status: Open' }),
|
|
101
|
+
)
|
|
102
|
+
expect(onRemove).toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('renders a chip without onRemove as a plain pill', () => {
|
|
106
|
+
render(() => <FilterBar chips={[{ label: 'Region: Europe' }]} />)
|
|
107
|
+
expect(screen.getByText('Region: Europe')).toBeTruthy()
|
|
108
|
+
expect(screen.queryByRole('button', { name: /Remove filter/ })).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('shows Clear all only while a filter is active, and reports it', async () => {
|
|
112
|
+
const onClearAll = vi.fn()
|
|
113
|
+
const idle = render(() => <FilterBar onClearAll={onClearAll} />)
|
|
114
|
+
expect(screen.queryByRole('button', { name: 'Clear all' })).toBeNull()
|
|
115
|
+
idle.unmount()
|
|
116
|
+
|
|
117
|
+
render(() => (
|
|
118
|
+
<FilterBar
|
|
119
|
+
onClearAll={onClearAll}
|
|
120
|
+
selects={[
|
|
121
|
+
{
|
|
122
|
+
label: 'Owner',
|
|
123
|
+
value: 'me',
|
|
124
|
+
options: [{ label: 'Me', value: 'me' }],
|
|
125
|
+
},
|
|
126
|
+
]}
|
|
127
|
+
/>
|
|
128
|
+
))
|
|
129
|
+
await userEvent.click(screen.getByRole('button', { name: 'Clear all' }))
|
|
130
|
+
expect(onClearAll).toHaveBeenCalled()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('renders and reports the view switcher', async () => {
|
|
134
|
+
const onValueChange = vi.fn()
|
|
135
|
+
render(() => (
|
|
136
|
+
<FilterBar
|
|
137
|
+
tabs={{
|
|
138
|
+
label: 'View',
|
|
139
|
+
value: 'list',
|
|
140
|
+
onValueChange,
|
|
141
|
+
options: [
|
|
142
|
+
{ label: 'List', value: 'list' },
|
|
143
|
+
{ label: 'Board', value: 'board' },
|
|
144
|
+
],
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
))
|
|
148
|
+
|
|
149
|
+
await userEvent.click(screen.getByRole('radio', { name: 'Board' }))
|
|
150
|
+
expect(onValueChange).toHaveBeenCalledWith('board')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Button, cn, Icon, TextInput } from '@talelk/newui-atoms'
|
|
2
|
+
import { Select, type SelectOption, TabButtons } from '@talelk/newui-molecules'
|
|
3
|
+
import { For, type JSX, onCleanup, onMount, Show } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
export interface FilterOption {
|
|
6
|
+
label: string
|
|
7
|
+
value: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FilterSelect {
|
|
11
|
+
/** Accessible name; also the placeholder when nothing is chosen. */
|
|
12
|
+
label: string
|
|
13
|
+
options: SelectOption[]
|
|
14
|
+
value?: string
|
|
15
|
+
onValueChange?: (value: string) => void
|
|
16
|
+
width?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FilterChip {
|
|
20
|
+
/** The active filter as the reader states it, e.g. `Status: Open`. */
|
|
21
|
+
label: string
|
|
22
|
+
/** When set, the chip carries a remove button that calls this. */
|
|
23
|
+
onRemove?: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FilterBarProps {
|
|
27
|
+
/** Rendered as a search input. Omit for a bar with no query box. */
|
|
28
|
+
query?: {
|
|
29
|
+
value?: string
|
|
30
|
+
placeholder?: string
|
|
31
|
+
onInput?: (value: string) => void
|
|
32
|
+
width?: string
|
|
33
|
+
}
|
|
34
|
+
/** One or more selects, in order. */
|
|
35
|
+
selects?: FilterSelect[]
|
|
36
|
+
/** A segmented control on the trailing edge — the view switcher. */
|
|
37
|
+
tabs?: {
|
|
38
|
+
/** Accessible name for the radiogroup. */
|
|
39
|
+
label: string
|
|
40
|
+
options: FilterOption[]
|
|
41
|
+
value?: string
|
|
42
|
+
onValueChange?: (value: string) => void
|
|
43
|
+
}
|
|
44
|
+
/** Active filters restated as removable pills under the bar. */
|
|
45
|
+
chips?: FilterChip[]
|
|
46
|
+
/**
|
|
47
|
+
* Renders a "Clear all" button while a filter is active (a chip, a filled
|
|
48
|
+
* select, or a non-empty query). The bar never clears anything itself —
|
|
49
|
+
* it reports the request and the caller resets its own state.
|
|
50
|
+
*/
|
|
51
|
+
onClearAll?: () => void
|
|
52
|
+
/** Rows the current filters match, stated under the bar (`24 results`). */
|
|
53
|
+
resultCount?: number
|
|
54
|
+
/** Anything else on the trailing edge, after the tabs. */
|
|
55
|
+
children?: JSX.Element
|
|
56
|
+
/** `bare` removes the card surface for a toolbar composed into a Section. */
|
|
57
|
+
surface?: 'card' | 'bare'
|
|
58
|
+
class?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when a keystroke is landing in something the user is already editing. */
|
|
62
|
+
function isEditable(target: EventTarget | null): boolean {
|
|
63
|
+
if (!(target instanceof HTMLElement)) return false
|
|
64
|
+
return (
|
|
65
|
+
target.isContentEditable ||
|
|
66
|
+
target instanceof HTMLInputElement ||
|
|
67
|
+
target instanceof HTMLTextAreaElement ||
|
|
68
|
+
target instanceof HTMLSelectElement
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The row of controls above a list: search, one or two selects, and the view
|
|
74
|
+
* switcher, on one quiet surface so the controls read as a single toolbar
|
|
75
|
+
* rather than loose fields above the data.
|
|
76
|
+
*
|
|
77
|
+
* Worth a block because the shape is a decision — search leads and is the widest
|
|
78
|
+
* target, selects are fixed-width so their labels do not jump as values change,
|
|
79
|
+
* and the view switcher is pushed to the far end because it changes how the data
|
|
80
|
+
* below is *drawn* rather than what is in it. A filled select takes the filled
|
|
81
|
+
* (`subtle`) surface, so what is filtering the list is visible without opening
|
|
82
|
+
* anything; the active set can also be restated as removable chips with a
|
|
83
|
+
* result count and a clear-all, which is the point where a row of controls
|
|
84
|
+
* becomes an answer to "why am I seeing these rows".
|
|
85
|
+
*
|
|
86
|
+
* The filter bar gives each Select an `aria-label` from its `label` and uses
|
|
87
|
+
* it as the placeholder, rather than rendering a form label above a toolbar.
|
|
88
|
+
* The search box carries a `/` hint and honours it: pressing `/` anywhere
|
|
89
|
+
* outside another editing surface focuses the box.
|
|
90
|
+
*/
|
|
91
|
+
export function FilterBar(props: FilterBarProps): JSX.Element {
|
|
92
|
+
let searchBox: HTMLSpanElement | undefined
|
|
93
|
+
|
|
94
|
+
onMount(() => {
|
|
95
|
+
if (!props.query) return
|
|
96
|
+
const onKey = (event: KeyboardEvent) => {
|
|
97
|
+
if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey)
|
|
98
|
+
return
|
|
99
|
+
if (isEditable(event.target)) return
|
|
100
|
+
const input = searchBox?.querySelector('input')
|
|
101
|
+
if (!input) return
|
|
102
|
+
event.preventDefault()
|
|
103
|
+
input.focus()
|
|
104
|
+
}
|
|
105
|
+
window.addEventListener('keydown', onKey)
|
|
106
|
+
onCleanup(() => window.removeEventListener('keydown', onKey))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const activeCount = () =>
|
|
110
|
+
(props.chips?.length ?? 0) +
|
|
111
|
+
(props.selects ?? []).filter((select) => Boolean(select.value)).length +
|
|
112
|
+
(props.query?.value ? 1 : 0)
|
|
113
|
+
const hasLeading = () =>
|
|
114
|
+
Boolean(props.query) || (props.selects?.length ?? 0) > 0
|
|
115
|
+
const showClearAll = () => props.onClearAll !== undefined && activeCount() > 0
|
|
116
|
+
const showMetaRow = () =>
|
|
117
|
+
(props.chips?.length ?? 0) > 0 ||
|
|
118
|
+
props.resultCount !== undefined ||
|
|
119
|
+
showClearAll()
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
data-slot="filter-bar"
|
|
124
|
+
data-surface={props.surface ?? 'card'}
|
|
125
|
+
class={cn(
|
|
126
|
+
'flex max-w-full flex-col gap-control-gap',
|
|
127
|
+
(props.surface ?? 'card') === 'card'
|
|
128
|
+
? 'rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-control-pad'
|
|
129
|
+
: 'p-0',
|
|
130
|
+
props.class,
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
<div class="grid max-w-full grid-cols-1 items-center gap-control-gap min-[360px]:grid-cols-2 xl:flex xl:flex-wrap">
|
|
134
|
+
<Show when={props.query}>
|
|
135
|
+
{(query) => (
|
|
136
|
+
<span
|
|
137
|
+
ref={searchBox}
|
|
138
|
+
class="col-span-full min-w-0 max-w-full xl:flex-1"
|
|
139
|
+
>
|
|
140
|
+
<TextInput
|
|
141
|
+
size="sm"
|
|
142
|
+
type="search"
|
|
143
|
+
value={query().value}
|
|
144
|
+
placeholder={query().placeholder ?? 'Search…'}
|
|
145
|
+
aria-label={query().placeholder ?? 'Search'}
|
|
146
|
+
class={`w-full ${query().width ?? ''}`}
|
|
147
|
+
prefix={<Icon icon="lucide-search" class="size-4" />}
|
|
148
|
+
suffix={
|
|
149
|
+
<kbd class="rounded-2 border border-outline-gray-2 bg-surface-gray-1 px-1 text-caption text-ink-gray-7">
|
|
150
|
+
/
|
|
151
|
+
</kbd>
|
|
152
|
+
}
|
|
153
|
+
onInput={(event) =>
|
|
154
|
+
query().onInput?.(event.currentTarget.value)
|
|
155
|
+
}
|
|
156
|
+
/>
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
</Show>
|
|
160
|
+
|
|
161
|
+
<For each={props.selects ?? []}>
|
|
162
|
+
{(select) => (
|
|
163
|
+
<div
|
|
164
|
+
class={`min-w-0 max-w-full sm:w-full xl:w-40 ${select.width ?? ''}`}
|
|
165
|
+
>
|
|
166
|
+
<Select
|
|
167
|
+
size="sm"
|
|
168
|
+
variant={select.value ? 'subtle' : 'outline'}
|
|
169
|
+
options={select.options}
|
|
170
|
+
value={select.value || null}
|
|
171
|
+
placeholder={select.label}
|
|
172
|
+
aria-label={select.label}
|
|
173
|
+
onValueChange={(value) => select.onValueChange?.(value ?? '')}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</For>
|
|
178
|
+
|
|
179
|
+
<div
|
|
180
|
+
class={cn(
|
|
181
|
+
'col-span-full flex min-w-0 max-w-full flex-wrap items-center gap-control-gap',
|
|
182
|
+
hasLeading() ? 'sm:justify-end xl:ms-auto' : 'sm:justify-start',
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
185
|
+
<Show when={props.tabs}>
|
|
186
|
+
{(tabs) => (
|
|
187
|
+
<TabButtons
|
|
188
|
+
label={tabs().label}
|
|
189
|
+
options={tabs().options}
|
|
190
|
+
value={tabs().value}
|
|
191
|
+
onValueChange={tabs().onValueChange}
|
|
192
|
+
/>
|
|
193
|
+
)}
|
|
194
|
+
</Show>
|
|
195
|
+
{props.children}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<Show when={showMetaRow()}>
|
|
200
|
+
<div
|
|
201
|
+
data-slot="filter-bar-meta"
|
|
202
|
+
class="flex max-w-full flex-wrap items-center gap-1.5 px-control-pad"
|
|
203
|
+
>
|
|
204
|
+
<Show when={props.resultCount !== undefined}>
|
|
205
|
+
<span
|
|
206
|
+
aria-live="polite"
|
|
207
|
+
class="me-1 text-caption tabular-nums text-ink-gray-7"
|
|
208
|
+
>
|
|
209
|
+
{props.resultCount}{' '}
|
|
210
|
+
{props.resultCount === 1 ? 'result' : 'results'}
|
|
211
|
+
</span>
|
|
212
|
+
</Show>
|
|
213
|
+
<For each={props.chips ?? []}>
|
|
214
|
+
{(chip) => (
|
|
215
|
+
<span
|
|
216
|
+
data-slot="filter-chip"
|
|
217
|
+
class={`inline-flex items-center gap-1 rounded-full border border-outline-gray-2 bg-surface-base py-0.5 ps-2 text-caption text-ink-gray-8 ${chip.onRemove ? 'pe-1' : 'pe-2'}`}
|
|
218
|
+
>
|
|
219
|
+
{chip.label}
|
|
220
|
+
<Show when={chip.onRemove}>
|
|
221
|
+
{(onRemove) => (
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
aria-label={`Remove filter: ${chip.label}`}
|
|
225
|
+
class="grid size-6 place-items-center rounded-full text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring motion-reduce:transition-none"
|
|
226
|
+
onClick={() => onRemove()()}
|
|
227
|
+
>
|
|
228
|
+
<Icon icon="lucide-x" class="size-3" />
|
|
229
|
+
</button>
|
|
230
|
+
)}
|
|
231
|
+
</Show>
|
|
232
|
+
</span>
|
|
233
|
+
)}
|
|
234
|
+
</For>
|
|
235
|
+
<Show when={showClearAll()}>
|
|
236
|
+
<span class="ms-auto">
|
|
237
|
+
<Button
|
|
238
|
+
variant="ghost"
|
|
239
|
+
theme="gray"
|
|
240
|
+
size="sm"
|
|
241
|
+
onClick={() => props.onClearAll?.()}
|
|
242
|
+
>
|
|
243
|
+
Clear all
|
|
244
|
+
</Button>
|
|
245
|
+
</span>
|
|
246
|
+
</Show>
|
|
247
|
+
</div>
|
|
248
|
+
</Show>
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FilterBar'
|