@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.
Files changed (29) hide show
  1. package/package.json +3 -3
  2. package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
  3. package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
  4. package/src/organisms/AuthCard/AuthCard.tsx +41 -32
  5. package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
  6. package/src/organisms/DataState/DataState.test.tsx +45 -0
  7. package/src/organisms/DataState/DataState.tsx +111 -0
  8. package/src/organisms/DataState/index.ts +6 -0
  9. package/src/organisms/DataTable/DataTable.test.tsx +73 -1
  10. package/src/organisms/DataTable/DataTable.tsx +426 -148
  11. package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
  12. package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
  13. package/src/organisms/FilterBar/FilterBar.tsx +24 -5
  14. package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
  15. package/src/organisms/MobileNav/MobileNav.tsx +1 -1
  16. package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
  17. package/src/organisms/Section/Section.test.tsx +41 -0
  18. package/src/organisms/Section/Section.tsx +108 -0
  19. package/src/organisms/Section/index.ts +1 -0
  20. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  21. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  22. package/src/organisms/SelectionBar/index.ts +5 -0
  23. package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
  24. package/src/organisms/Sidebar/Sidebar.tsx +17 -10
  25. package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
  26. package/src/organisms/StatGrid/StatGrid.tsx +98 -71
  27. package/src/organisms/VirtualList/VirtualList.tsx +56 -22
  28. package/src/organisms/index.ts +3 -0
  29. 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 { Dropdown } from '@talelk/newui-molecules'
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
- <span class="truncate text-heading text-ink-gray-9">{props.title}</span>
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
- <Dropdown
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
- <span class="sr-only">Page actions</span>
55
- </Dropdown>
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 ?? 'lucide-plus'}
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
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ SelectionBar,
3
+ type SelectionBarProps,
4
+ type SelectionPrimaryAction,
5
+ } from './SelectionBar'
@@ -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
- 'sm:h-[min(860px,calc(100vh-8rem))] sm:max-h-[calc(100vh_-_8rem)] sm:min-h-[560px] sm:w-auto sm:max-w-4xl sm:flex-row sm:rounded-overlay',
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-r',
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 px-4 pt-6 sm:px-6', props.class)}
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 (local.shortcut === false || !collapsible()) return
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 value={{ collapsed: shouldCollapse, toggle }}>
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-1',
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-7 items-center pl-2', local.class)}
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-base leading-tighter text-ink-gray-7',
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-6',
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, per state. */
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>{`${text()} · ⌘B`}</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('sm:grid-cols-2')
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
  })