@talelk/newui-organisms 1.0.0 → 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/package.json +3 -3
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
- package/src/organisms/AuthCard/AuthCard.tsx +41 -32
- package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
- 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 +73 -1
- package/src/organisms/DataTable/DataTable.tsx +426 -148
- package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
- package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
- package/src/organisms/FilterBar/FilterBar.tsx +24 -5
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
- package/src/organisms/MobileNav/MobileNav.tsx +1 -1
- package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
- 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.tsx +13 -4
- package/src/organisms/Sidebar/Sidebar.tsx +17 -10
- package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
- package/src/organisms/StatGrid/StatGrid.tsx +98 -71
- package/src/organisms/VirtualList/VirtualList.tsx +56 -22
- package/src/organisms/index.ts +3 -0
- package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
|
@@ -1,18 +1,25 @@
|
|
|
1
|
-
import { Button, type IconProp } from '@talelk/newui-atoms'
|
|
2
|
-
import {
|
|
3
|
-
import { type JSX, Show } from 'solid-js'
|
|
1
|
+
import { Button, cn, Icon, type IconProp } from '@talelk/newui-atoms'
|
|
2
|
+
import { Menu } from '@talelk/newui-molecules'
|
|
3
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
4
|
+
import { Dynamic } from 'solid-js/web'
|
|
4
5
|
|
|
5
6
|
export interface PageToolbarAction {
|
|
6
7
|
label: string
|
|
7
|
-
/** A `lucide-*` string or a component (P11). */
|
|
8
|
+
/** A `lucide-*` string or a component (P11). No icon is implied. */
|
|
8
9
|
icon?: IconProp
|
|
9
10
|
onClick?: () => void
|
|
11
|
+
loading?: boolean
|
|
12
|
+
disabled?: boolean
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
export interface PageToolbarProps {
|
|
13
16
|
title: JSX.Element
|
|
17
|
+
/** Semantic level of the title. @default 1 */
|
|
18
|
+
headingLevel?: 1 | 2 | 3 | 4
|
|
14
19
|
/** Options for the ellipsis menu beside the title. Omitted → no menu. */
|
|
15
20
|
menu?: PageToolbarAction[]
|
|
21
|
+
/** Secondary actions, before the primary. */
|
|
22
|
+
secondary?: PageToolbarAction[]
|
|
16
23
|
/** The primary action — the one thing this screen is for. */
|
|
17
24
|
action?: PageToolbarAction
|
|
18
25
|
/** Anything else on the trailing edge, after the primary action. */
|
|
@@ -36,35 +43,77 @@ export function PageToolbar(props: PageToolbarProps): JSX.Element {
|
|
|
36
43
|
class={`flex min-h-12 shrink-0 items-center justify-between gap-control-gap border-b border-outline-gray-1 bg-surface-base px-page-gutter-compact sm:px-page-gutter ${props.class ?? ''}`}
|
|
37
44
|
>
|
|
38
45
|
<div class="flex min-w-0 items-center gap-1">
|
|
39
|
-
<
|
|
46
|
+
<Dynamic
|
|
47
|
+
component={`h${props.headingLevel ?? 1}`}
|
|
48
|
+
class={cn(
|
|
49
|
+
'truncate text-ink-gray-9',
|
|
50
|
+
(props.headingLevel ?? 1) === 1 ? 'text-heading' : 'text-section',
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
{props.title}
|
|
54
|
+
</Dynamic>
|
|
40
55
|
<Show when={props.menu}>
|
|
41
56
|
{(menu) => (
|
|
42
|
-
<
|
|
43
|
-
options={menu().map((entry) => ({
|
|
44
|
-
label: entry.label,
|
|
45
|
-
value: entry.label,
|
|
46
|
-
...(typeof entry.icon === 'string' ? { icon: entry.icon } : {}),
|
|
47
|
-
}))}
|
|
57
|
+
<Menu.Root
|
|
48
58
|
onSelect={(value) =>
|
|
49
59
|
menu()
|
|
50
60
|
.find((entry) => entry.label === value)
|
|
51
61
|
?.onClick?.()
|
|
52
62
|
}
|
|
53
63
|
>
|
|
54
|
-
<
|
|
55
|
-
|
|
64
|
+
<Menu.Trigger
|
|
65
|
+
aria-label="Page actions"
|
|
66
|
+
class="grid size-7 shrink-0 place-items-center rounded-3 text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring"
|
|
67
|
+
>
|
|
68
|
+
<Icon icon="lucide-ellipsis" class="size-4" />
|
|
69
|
+
</Menu.Trigger>
|
|
70
|
+
<Menu.Content>
|
|
71
|
+
<For each={menu()}>
|
|
72
|
+
{(entry) => (
|
|
73
|
+
<Menu.Item value={entry.label}>
|
|
74
|
+
<Show when={entry.icon}>
|
|
75
|
+
{(icon) => (
|
|
76
|
+
<Icon
|
|
77
|
+
icon={icon()}
|
|
78
|
+
class="size-4 shrink-0 text-ink-gray-6"
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
</Show>
|
|
82
|
+
<span>{entry.label}</span>
|
|
83
|
+
</Menu.Item>
|
|
84
|
+
)}
|
|
85
|
+
</For>
|
|
86
|
+
</Menu.Content>
|
|
87
|
+
</Menu.Root>
|
|
56
88
|
)}
|
|
57
89
|
</Show>
|
|
58
90
|
</div>
|
|
59
91
|
|
|
60
92
|
<div class="flex shrink-0 items-center gap-control-gap">
|
|
61
93
|
{props.children}
|
|
94
|
+
<Show when={props.secondary}>
|
|
95
|
+
<For each={props.secondary}>
|
|
96
|
+
{(action) => (
|
|
97
|
+
<Button
|
|
98
|
+
variant="outline"
|
|
99
|
+
iconLeft={action.icon}
|
|
100
|
+
loading={action.loading}
|
|
101
|
+
disabled={action.disabled}
|
|
102
|
+
onClick={action.onClick}
|
|
103
|
+
>
|
|
104
|
+
{action.label}
|
|
105
|
+
</Button>
|
|
106
|
+
)}
|
|
107
|
+
</For>
|
|
108
|
+
</Show>
|
|
62
109
|
<Show when={props.action}>
|
|
63
110
|
{(action) => (
|
|
64
111
|
<Button
|
|
65
112
|
variant="solid"
|
|
66
113
|
theme="blue"
|
|
67
|
-
iconLeft={action().icon
|
|
114
|
+
iconLeft={action().icon}
|
|
115
|
+
loading={action().loading}
|
|
116
|
+
disabled={action().disabled}
|
|
68
117
|
onClick={action().onClick}
|
|
69
118
|
>
|
|
70
119
|
{action().label}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { Section } from './Section'
|
|
4
|
+
|
|
5
|
+
describe('Section', () => {
|
|
6
|
+
it('renders a default H2 and the four region slots', () => {
|
|
7
|
+
render(() => (
|
|
8
|
+
<Section
|
|
9
|
+
title="Pending expenses"
|
|
10
|
+
description="Waiting for approval"
|
|
11
|
+
actions={<button type="button">Export</button>}
|
|
12
|
+
toolbar={<div>Filters</div>}
|
|
13
|
+
footer={<span>18 records</span>}
|
|
14
|
+
>
|
|
15
|
+
<div>Body</div>
|
|
16
|
+
</Section>
|
|
17
|
+
))
|
|
18
|
+
expect(screen.getByRole('heading', { level: 2 }).textContent).toBe(
|
|
19
|
+
'Pending expenses',
|
|
20
|
+
)
|
|
21
|
+
expect(screen.getByText('Waiting for approval')).toBeTruthy()
|
|
22
|
+
expect(screen.getByRole('button', { name: 'Export' })).toBeTruthy()
|
|
23
|
+
expect(screen.getByText('Filters')).toBeTruthy()
|
|
24
|
+
expect(screen.getByText('Body')).toBeTruthy()
|
|
25
|
+
expect(screen.getByText('18 records')).toBeTruthy()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('honours the heading level and compact density', () => {
|
|
29
|
+
const { container } = render(() => (
|
|
30
|
+
<Section title="Ledger" headingLevel={3} density="compact">
|
|
31
|
+
<div />
|
|
32
|
+
</Section>
|
|
33
|
+
))
|
|
34
|
+
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Ledger')
|
|
35
|
+
expect(
|
|
36
|
+
container
|
|
37
|
+
.querySelector('[data-slot="section"]')
|
|
38
|
+
?.getAttribute('data-density'),
|
|
39
|
+
).toBe('compact')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { cn } from '@talelk/newui-atoms'
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
import { Show } from 'solid-js'
|
|
4
|
+
import { Dynamic } from 'solid-js/web'
|
|
5
|
+
|
|
6
|
+
export type SectionHeadingLevel = 1 | 2 | 3 | 4
|
|
7
|
+
|
|
8
|
+
export interface SectionProps {
|
|
9
|
+
title?: JSX.Element
|
|
10
|
+
description?: JSX.Element
|
|
11
|
+
/** Page or section actions, rendered opposite the title. */
|
|
12
|
+
actions?: JSX.Element
|
|
13
|
+
/** Controls that change the data, between the header and the body. */
|
|
14
|
+
toolbar?: JSX.Element
|
|
15
|
+
/** Pagination, totals or selection feedback, under a rule. */
|
|
16
|
+
footer?: JSX.Element
|
|
17
|
+
/** @default 2 */
|
|
18
|
+
headingLevel?: SectionHeadingLevel
|
|
19
|
+
/** Explicit id for the rendered heading. Defaults to `<id>-title`. */
|
|
20
|
+
headingId?: string
|
|
21
|
+
/** Accessible name when the section has no visible title. */
|
|
22
|
+
label?: string
|
|
23
|
+
/** Id of the element that names the region; beats `label`/the heading id. */
|
|
24
|
+
labelledBy?: string
|
|
25
|
+
/** Surface treatment. @default 'plain' */
|
|
26
|
+
surface?: 'plain' | 'card' | 'inset'
|
|
27
|
+
/** @default 'comfortable' */
|
|
28
|
+
density?: 'comfortable' | 'compact'
|
|
29
|
+
id?: string
|
|
30
|
+
class?: string
|
|
31
|
+
children: JSX.Element
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HEADING: Record<SectionHeadingLevel, string> = {
|
|
35
|
+
1: 'text-heading',
|
|
36
|
+
2: 'text-section',
|
|
37
|
+
3: 'text-base-medium',
|
|
38
|
+
4: 'text-interface font-medium',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One page-region anatomy: header, optional toolbar, body, optional footer.
|
|
43
|
+
*
|
|
44
|
+
* Data organisms should not each invent their own title row or footer rule.
|
|
45
|
+
* `Section` owns the region; the organism owns the data surface inside it.
|
|
46
|
+
*/
|
|
47
|
+
export function Section(props: SectionProps): JSX.Element {
|
|
48
|
+
const surface = () => props.surface ?? 'plain'
|
|
49
|
+
const density = () => props.density ?? 'comfortable'
|
|
50
|
+
const hasHeader = () =>
|
|
51
|
+
Boolean(props.title || props.description || props.actions)
|
|
52
|
+
const headingId = () =>
|
|
53
|
+
props.headingId ?? (props.id ? `${props.id}-title` : undefined)
|
|
54
|
+
const labelledBy = () =>
|
|
55
|
+
props.labelledBy ?? (props.title ? headingId() : undefined)
|
|
56
|
+
return (
|
|
57
|
+
<section
|
|
58
|
+
id={props.id}
|
|
59
|
+
aria-label={props.label}
|
|
60
|
+
aria-labelledby={labelledBy()}
|
|
61
|
+
data-slot="section"
|
|
62
|
+
data-surface={surface()}
|
|
63
|
+
data-density={density()}
|
|
64
|
+
class={cn(
|
|
65
|
+
'flex min-w-0 flex-col',
|
|
66
|
+
density() === 'compact' ? 'gap-2' : 'gap-3',
|
|
67
|
+
surface() === 'card' &&
|
|
68
|
+
'rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-card-pad',
|
|
69
|
+
surface() === 'inset' && 'rounded-card bg-surface-gray-1 p-card-pad',
|
|
70
|
+
props.class,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
<Show when={hasHeader()}>
|
|
74
|
+
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
75
|
+
<div class="flex min-w-0 flex-col gap-1">
|
|
76
|
+
<Show when={props.title}>
|
|
77
|
+
<Dynamic
|
|
78
|
+
component={`h${props.headingLevel ?? 2}`}
|
|
79
|
+
id={headingId()}
|
|
80
|
+
class={cn(
|
|
81
|
+
'min-w-0 text-ink-gray-9',
|
|
82
|
+
HEADING[props.headingLevel ?? 2],
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{props.title}
|
|
86
|
+
</Dynamic>
|
|
87
|
+
</Show>
|
|
88
|
+
<Show when={props.description}>
|
|
89
|
+
<p class="max-w-[56ch] text-caption text-ink-gray-7">
|
|
90
|
+
{props.description}
|
|
91
|
+
</p>
|
|
92
|
+
</Show>
|
|
93
|
+
</div>
|
|
94
|
+
<Show when={props.actions}>
|
|
95
|
+
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
|
96
|
+
{props.actions}
|
|
97
|
+
</div>
|
|
98
|
+
</Show>
|
|
99
|
+
</div>
|
|
100
|
+
</Show>
|
|
101
|
+
<Show when={props.toolbar}>{props.toolbar}</Show>
|
|
102
|
+
<div class="min-w-0">{props.children}</div>
|
|
103
|
+
<Show when={props.footer}>
|
|
104
|
+
<div class="border-t border-outline-gray-1 pt-3">{props.footer}</div>
|
|
105
|
+
</Show>
|
|
106
|
+
</section>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Section, type SectionHeadingLevel, type SectionProps } from './Section'
|
|
@@ -0,0 +1,35 @@
|
|
|
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 { SelectionBar } from './SelectionBar'
|
|
5
|
+
|
|
6
|
+
describe('SelectionBar', () => {
|
|
7
|
+
it('renders nothing without a selection', () => {
|
|
8
|
+
const { container } = render(() => <SelectionBar count={0} />)
|
|
9
|
+
expect(container.querySelector('[data-slot="selection-bar"]')).toBeNull()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('shows the count, action and clear control', async () => {
|
|
13
|
+
const onAction = vi.fn()
|
|
14
|
+
const onClear = vi.fn()
|
|
15
|
+
render(() => (
|
|
16
|
+
<SelectionBar
|
|
17
|
+
count={3}
|
|
18
|
+
summary="$14,650"
|
|
19
|
+
primary={{ label: 'Approve expenses', onClick: onAction }}
|
|
20
|
+
onClear={onClear}
|
|
21
|
+
/>
|
|
22
|
+
))
|
|
23
|
+
expect(
|
|
24
|
+
screen.getByRole('region', { name: 'Selection actions' }),
|
|
25
|
+
).toBeTruthy()
|
|
26
|
+
expect(screen.getByText('3 selected')).toBeTruthy()
|
|
27
|
+
expect(screen.getByText('$14,650')).toBeTruthy()
|
|
28
|
+
await userEvent.click(
|
|
29
|
+
screen.getByRole('button', { name: 'Approve expenses' }),
|
|
30
|
+
)
|
|
31
|
+
await userEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
|
32
|
+
expect(onAction).toHaveBeenCalled()
|
|
33
|
+
expect(onClear).toHaveBeenCalled()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Button, cn, type IconProp } from '@talelk/newui-atoms'
|
|
2
|
+
import { type JSX, Show } from 'solid-js'
|
|
3
|
+
|
|
4
|
+
export interface SelectionPrimaryAction {
|
|
5
|
+
label: string
|
|
6
|
+
onClick: () => void
|
|
7
|
+
icon?: IconProp
|
|
8
|
+
disabled?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SelectionBarProps {
|
|
12
|
+
count: number
|
|
13
|
+
/** Context beside the count, e.g. a total or a scope. */
|
|
14
|
+
summary?: JSX.Element
|
|
15
|
+
primary?: SelectionPrimaryAction
|
|
16
|
+
secondary?: JSX.Element
|
|
17
|
+
onClear?: () => void
|
|
18
|
+
/** `sticky` fixes the bar to the bottom below `sm`. @default 'inline' */
|
|
19
|
+
placement?: 'inline' | 'sticky'
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The one bulk-action surface for a data region.
|
|
25
|
+
*
|
|
26
|
+
* Inline on desktop, fixed above the bottom edge on a phone, so selecting a
|
|
27
|
+
* row far down a card list never requires scrolling back to a toolbar.
|
|
28
|
+
*/
|
|
29
|
+
export function SelectionBar(props: SelectionBarProps): JSX.Element {
|
|
30
|
+
return (
|
|
31
|
+
<Show when={props.count > 0}>
|
|
32
|
+
<section
|
|
33
|
+
aria-label="Selection actions"
|
|
34
|
+
data-slot="selection-bar"
|
|
35
|
+
data-placement={props.placement ?? 'inline'}
|
|
36
|
+
class={cn(
|
|
37
|
+
'flex flex-wrap items-center justify-between gap-2 rounded-card border border-outline-gray-1 bg-surface-elevation-1 p-2 shadow-sm',
|
|
38
|
+
props.placement === 'sticky' &&
|
|
39
|
+
'max-sm:fixed max-sm:inset-x-4 max-sm:bottom-4 max-sm:z-top max-sm:shadow-lg',
|
|
40
|
+
props.class,
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
44
|
+
<span class="text-interface font-medium text-ink-gray-9">
|
|
45
|
+
{props.count} selected
|
|
46
|
+
</span>
|
|
47
|
+
<Show when={props.summary}>
|
|
48
|
+
<span class="text-caption tabular-nums text-ink-gray-7">
|
|
49
|
+
{props.summary}
|
|
50
|
+
</span>
|
|
51
|
+
</Show>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
54
|
+
{props.secondary}
|
|
55
|
+
<Show when={props.onClear}>
|
|
56
|
+
<Button
|
|
57
|
+
variant="ghost"
|
|
58
|
+
size="sm"
|
|
59
|
+
class="max-sm:h-10"
|
|
60
|
+
onClick={props.onClear}
|
|
61
|
+
>
|
|
62
|
+
Clear
|
|
63
|
+
</Button>
|
|
64
|
+
</Show>
|
|
65
|
+
<Show when={props.primary}>
|
|
66
|
+
{(primary) => (
|
|
67
|
+
<Button
|
|
68
|
+
variant="solid"
|
|
69
|
+
theme="blue"
|
|
70
|
+
size="sm"
|
|
71
|
+
class="max-sm:h-10"
|
|
72
|
+
iconLeft={primary().icon}
|
|
73
|
+
disabled={primary().disabled}
|
|
74
|
+
onClick={primary().onClick}
|
|
75
|
+
>
|
|
76
|
+
{primary().label}
|
|
77
|
+
</Button>
|
|
78
|
+
)}
|
|
79
|
+
</Show>
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
</Show>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -97,7 +97,8 @@ const Root: ParentComponent<SettingsDialogProps> = (props) => {
|
|
|
97
97
|
<Dialog.Content
|
|
98
98
|
class={cn(
|
|
99
99
|
'flex h-[100dvh] min-h-0 w-screen max-w-none flex-col overflow-hidden rounded-none p-0',
|
|
100
|
-
'
|
|
100
|
+
'max-sm:fixed max-sm:inset-0',
|
|
101
|
+
'sm:h-[min(860px,calc(100vh-8rem))] sm:max-h-[calc(100vh_-_8rem)] sm:min-h-[560px] sm:w-[min(56rem,calc(100vw-2rem))] sm:max-w-4xl sm:flex-row sm:rounded-overlay',
|
|
101
102
|
local.class,
|
|
102
103
|
)}
|
|
103
104
|
>
|
|
@@ -108,6 +109,10 @@ const Root: ParentComponent<SettingsDialogProps> = (props) => {
|
|
|
108
109
|
{local.description ??
|
|
109
110
|
'Manage your settings across the panels in this dialog.'}
|
|
110
111
|
</Dialog.Description>
|
|
112
|
+
<Dialog.Close class="absolute end-2 top-2 z-raised grid size-10 place-items-center rounded-control text-ink-gray-6 transition-colors hover:bg-surface-gray-2 hover:text-ink-gray-9 focus-visible:focus-ring sm:size-8">
|
|
113
|
+
<span class="sr-only">Close settings</span>
|
|
114
|
+
<span class="lucide-x size-4" aria-hidden="true" />
|
|
115
|
+
</Dialog.Close>
|
|
111
116
|
{local.children}
|
|
112
117
|
</Dialog.Content>
|
|
113
118
|
</SettingsDialogContext.Provider>
|
|
@@ -116,6 +121,8 @@ const Root: ParentComponent<SettingsDialogProps> = (props) => {
|
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
export interface SettingsSidebarProps {
|
|
124
|
+
/** Accessible name for the vertical tablist. @default 'Settings' */
|
|
125
|
+
label?: string
|
|
119
126
|
/** Merged into the sidebar. */
|
|
120
127
|
class?: string
|
|
121
128
|
children?: JSX.Element
|
|
@@ -128,11 +135,12 @@ export interface SettingsSidebarProps {
|
|
|
128
135
|
const Sidebar: ParentComponent<SettingsSidebarProps> = (props) => (
|
|
129
136
|
<div
|
|
130
137
|
role="tablist"
|
|
138
|
+
aria-label={props.label ?? 'Settings'}
|
|
131
139
|
aria-orientation="vertical"
|
|
132
140
|
data-slot="settings-sidebar"
|
|
133
141
|
class={cn(
|
|
134
142
|
'flex max-h-[38vh] w-full shrink-0 flex-col gap-4 overflow-y-auto border-b border-outline-gray-1 bg-surface-sidebar p-2',
|
|
135
|
-
'sm:max-h-none sm:w-[220px] sm:border-b-0 sm:border-
|
|
143
|
+
'max-sm:pe-12 sm:max-h-none sm:w-[220px] sm:border-b-0 sm:border-e',
|
|
136
144
|
props.class,
|
|
137
145
|
)}
|
|
138
146
|
>
|
|
@@ -241,8 +249,9 @@ const NavItem: ParentComponent<SettingsNavItemProps> = (props) => {
|
|
|
241
249
|
class={cn(
|
|
242
250
|
'flex min-h-8 w-full items-center gap-2 rounded-control px-2 py-1 text-start text-interface text-ink-gray-7 transition-colors motion-reduce:transition-none',
|
|
243
251
|
'focus-visible:focus-ring',
|
|
252
|
+
'min-h-10 sm:min-h-8',
|
|
244
253
|
'data-[state=inactive]:hover:bg-surface-gray-2',
|
|
245
|
-
'data-[state=active]:bg-surface-elevation-3 data-[state=active]:shadow-sm',
|
|
254
|
+
'data-[state=active]:bg-surface-elevation-3 data-[state=active]:text-ink-gray-9 data-[state=active]:shadow-sm',
|
|
246
255
|
'disabled:cursor-not-allowed disabled:text-ink-gray-4',
|
|
247
256
|
local.class,
|
|
248
257
|
)}
|
|
@@ -377,7 +386,7 @@ export interface SettingsHeaderProps {
|
|
|
377
386
|
const Header: ParentComponent<SettingsHeaderProps> = (props) => (
|
|
378
387
|
<div
|
|
379
388
|
data-slot="settings-header"
|
|
380
|
-
class={cn('shrink-0
|
|
389
|
+
class={cn('shrink-0 ps-4 pe-12 pt-6 sm:ps-6', props.class)}
|
|
381
390
|
>
|
|
382
391
|
<Show
|
|
383
392
|
when={props.children}
|
|
@@ -33,11 +33,14 @@ const COLLAPSE_QUERY = '(max-width: 639px)'
|
|
|
33
33
|
interface SidebarContextValue {
|
|
34
34
|
collapsed: Accessor<boolean>
|
|
35
35
|
toggle: () => void
|
|
36
|
+
/** Whether `⌘B`/`Ctrl+B` is bound here, so chrome only advertises a real hint. */
|
|
37
|
+
shortcut: Accessor<boolean>
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
const SidebarContext = createContext<SidebarContextValue>({
|
|
39
41
|
collapsed: () => false,
|
|
40
42
|
toggle: () => {},
|
|
43
|
+
shortcut: () => false,
|
|
41
44
|
})
|
|
42
45
|
|
|
43
46
|
/** Collapsed state + toggle, shared with every part inside a `Sidebar`. */
|
|
@@ -83,6 +86,7 @@ export const Sidebar: ParentComponent<SidebarProps> = (props) => {
|
|
|
83
86
|
const [internal, setInternal] = createSignal<boolean | null>(null)
|
|
84
87
|
|
|
85
88
|
const collapsible = () => local.collapsible ?? true
|
|
89
|
+
const shortcut = () => local.shortcut !== false && collapsible()
|
|
86
90
|
const shouldCollapse = () =>
|
|
87
91
|
collapsible() &&
|
|
88
92
|
(local.collapsed !== undefined
|
|
@@ -96,7 +100,7 @@ export const Sidebar: ParentComponent<SidebarProps> = (props) => {
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
onMount(() => {
|
|
99
|
-
if (
|
|
103
|
+
if (!shortcut()) return
|
|
100
104
|
const onKey = (event: KeyboardEvent) => {
|
|
101
105
|
if (event.key.toLowerCase() !== 'b') return
|
|
102
106
|
if (!event.metaKey && !event.ctrlKey) return
|
|
@@ -108,7 +112,9 @@ export const Sidebar: ParentComponent<SidebarProps> = (props) => {
|
|
|
108
112
|
})
|
|
109
113
|
|
|
110
114
|
return (
|
|
111
|
-
<SidebarContext.Provider
|
|
115
|
+
<SidebarContext.Provider
|
|
116
|
+
value={{ collapsed: shouldCollapse, toggle, shortcut }}
|
|
117
|
+
>
|
|
112
118
|
<nav
|
|
113
119
|
data-slot="sidebar"
|
|
114
120
|
data-state={shouldCollapse() ? 'collapsed' : 'expanded'}
|
|
@@ -162,7 +168,7 @@ export const SidebarHeader: ParentComponent<SidebarHeaderProps> = (props) => {
|
|
|
162
168
|
<div
|
|
163
169
|
data-slot="sidebar-header"
|
|
164
170
|
class={cn(
|
|
165
|
-
'flex h-12 shrink-0 items-center justify-between gap-2 px-
|
|
171
|
+
'flex h-12 shrink-0 items-center justify-between gap-2 px-2',
|
|
166
172
|
local.class,
|
|
167
173
|
)}
|
|
168
174
|
>
|
|
@@ -235,11 +241,11 @@ export const SidebarLabel: ParentComponent<SidebarLabelProps> = (props) => {
|
|
|
235
241
|
return (
|
|
236
242
|
<div
|
|
237
243
|
data-slot="sidebar-label"
|
|
238
|
-
class={cn('relative flex h-
|
|
244
|
+
class={cn('relative flex h-8 items-center px-2 pt-2', local.class)}
|
|
239
245
|
>
|
|
240
246
|
<div
|
|
241
247
|
class={cn(
|
|
242
|
-
'text-
|
|
248
|
+
'text-xs font-medium text-ink-gray-6',
|
|
243
249
|
collapsed() ? 'w-0 overflow-hidden opacity-0' : 'w-auto opacity-100',
|
|
244
250
|
)}
|
|
245
251
|
>
|
|
@@ -319,9 +325,9 @@ export const SidebarItem: ParentComponent<SidebarItemProps> = (props) => {
|
|
|
319
325
|
|
|
320
326
|
const rowClasses = () =>
|
|
321
327
|
cn(
|
|
322
|
-
'relative flex h-8 w-full items-center rounded-control text-interface text-ink-gray-
|
|
328
|
+
'group relative flex h-8 w-full items-center rounded-control text-interface text-ink-gray-7',
|
|
323
329
|
'transition-colors hover:bg-surface-gray-2 motion-reduce:transition-none',
|
|
324
|
-
'data-[active]:bg-surface-blue-1 data-[active]:text-ink-blue-8',
|
|
330
|
+
'data-[active]:bg-surface-blue-1 data-[active]:font-medium data-[active]:text-ink-blue-8',
|
|
325
331
|
local.class,
|
|
326
332
|
)
|
|
327
333
|
const innerClasses = () =>
|
|
@@ -439,7 +445,7 @@ export function SidebarSearch(props: SidebarSearchProps): JSX.Element {
|
|
|
439
445
|
}
|
|
440
446
|
|
|
441
447
|
export interface SidebarTriggerProps {
|
|
442
|
-
/** Tooltip copy. Defaults to Collapse/Expand
|
|
448
|
+
/** Tooltip and accessible copy. Defaults to Collapse sidebar/Expand sidebar. */
|
|
443
449
|
label?: string
|
|
444
450
|
class?: string
|
|
445
451
|
}
|
|
@@ -453,9 +459,10 @@ export interface SidebarTriggerProps {
|
|
|
453
459
|
* `SidebarItem` at the bottom of the list); this is the compact one.
|
|
454
460
|
*/
|
|
455
461
|
export function SidebarTrigger(props: SidebarTriggerProps): JSX.Element {
|
|
456
|
-
const { collapsed, toggle } = useSidebar()
|
|
462
|
+
const { collapsed, toggle, shortcut } = useSidebar()
|
|
457
463
|
const text = () =>
|
|
458
464
|
props.label ?? (collapsed() ? 'Expand sidebar' : 'Collapse sidebar')
|
|
465
|
+
const hint = () => (shortcut() ? `${text()} · ⌘B` : text())
|
|
459
466
|
|
|
460
467
|
return (
|
|
461
468
|
<TooltipRoot placement="bottom" openDelay={300}>
|
|
@@ -478,7 +485,7 @@ export function SidebarTrigger(props: SidebarTriggerProps): JSX.Element {
|
|
|
478
485
|
)}
|
|
479
486
|
/>
|
|
480
487
|
</TooltipTrigger>
|
|
481
|
-
<TooltipContent>{
|
|
488
|
+
<TooltipContent>{hint()}</TooltipContent>
|
|
482
489
|
</TooltipRoot>
|
|
483
490
|
)
|
|
484
491
|
}
|
|
@@ -77,7 +77,19 @@ describe('StatGrid', () => {
|
|
|
77
77
|
const className = container.querySelector(
|
|
78
78
|
'[data-slot="stat-grid"]',
|
|
79
79
|
)?.className
|
|
80
|
-
expect(className).toContain('
|
|
80
|
+
expect(className).toContain('min-[360px]:grid-cols-2')
|
|
81
81
|
expect(className).toContain('xl:grid-cols-3')
|
|
82
82
|
})
|
|
83
|
+
|
|
84
|
+
it('compact cards use the tight padding and omit the icon tile', () => {
|
|
85
|
+
const { container } = render(() => (
|
|
86
|
+
<StatGrid
|
|
87
|
+
compact
|
|
88
|
+
stats={[{ label: 'One', value: '1', icon: 'lucide-house' }]}
|
|
89
|
+
/>
|
|
90
|
+
))
|
|
91
|
+
const card = container.querySelector('[data-slot="stat"]')
|
|
92
|
+
expect(card?.className).toContain('p-2.5')
|
|
93
|
+
expect(card?.querySelector('.bg-surface-gray-2')).toBeNull()
|
|
94
|
+
})
|
|
83
95
|
})
|