@talelk/newui-organisms 1.0.0 → 2.1.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 (50) hide show
  1. package/package.json +8 -4
  2. package/skills/newui-organisms/SKILL.md +91 -0
  3. package/src/exports.test.ts +3 -0
  4. package/src/organisms/AppSidebar/AppSidebar.a11y.test.tsx +75 -0
  5. package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
  6. package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
  7. package/src/organisms/AuthCard/AuthCard.tsx +41 -32
  8. package/src/organisms/CommandPalette/CommandPalette.a11y.test.tsx +58 -0
  9. package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
  10. package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
  11. package/src/organisms/DataState/DataState.browser.test.tsx +52 -0
  12. package/src/organisms/DataState/DataState.test.tsx +45 -0
  13. package/src/organisms/DataState/DataState.tsx +111 -0
  14. package/src/organisms/DataState/index.ts +6 -0
  15. package/src/organisms/DataTable/DataTable.a11y.test.tsx +71 -0
  16. package/src/organisms/DataTable/DataTable.features.test.tsx +191 -0
  17. package/src/organisms/DataTable/DataTable.test.tsx +73 -1
  18. package/src/organisms/DataTable/DataTable.tsx +609 -149
  19. package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
  20. package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
  21. package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
  22. package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
  23. package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
  24. package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
  25. package/src/organisms/FilterBar/FilterBar.tsx +24 -5
  26. package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
  27. package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
  29. package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
  30. package/src/organisms/MobileNav/MobileNav.tsx +1 -1
  31. package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
  32. package/src/organisms/Section/Section.a11y.test.tsx +49 -0
  33. package/src/organisms/Section/Section.browser.test.tsx +63 -0
  34. package/src/organisms/Section/Section.test.tsx +41 -0
  35. package/src/organisms/Section/Section.tsx +108 -0
  36. package/src/organisms/Section/index.ts +1 -0
  37. package/src/organisms/SelectionBar/SelectionBar.a11y.test.tsx +48 -0
  38. package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -0
  39. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  40. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  41. package/src/organisms/SelectionBar/index.ts +5 -0
  42. package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
  43. package/src/organisms/Sidebar/Sidebar.a11y.test.tsx +71 -0
  44. package/src/organisms/Sidebar/Sidebar.tsx +17 -10
  45. package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
  46. package/src/organisms/StatGrid/StatGrid.tsx +98 -71
  47. package/src/organisms/VirtualList/VirtualList.a11y.test.tsx +109 -0
  48. package/src/organisms/VirtualList/VirtualList.tsx +56 -22
  49. package/src/organisms/index.ts +3 -0
  50. package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
@@ -0,0 +1,49 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { Section } from './Section'
5
+
6
+ const AXE_OPTIONS = {
7
+ rules: {
8
+ 'color-contrast': { enabled: false },
9
+ region: { enabled: false },
10
+ },
11
+ }
12
+
13
+ function summarize(results: AxeResults) {
14
+ return results.violations.map((violation) => ({
15
+ id: violation.id,
16
+ impact: violation.impact,
17
+ targets: violation.nodes.map((node) => node.target),
18
+ }))
19
+ }
20
+
21
+ describe('Section accessibility', () => {
22
+ it('has no axe violations for the full region anatomy', async () => {
23
+ const { container } = render(() => (
24
+ <Section
25
+ id="expenses"
26
+ title="Pending expenses"
27
+ description="Waiting for approval"
28
+ actions={<button type="button">Export</button>}
29
+ toolbar={<div>Filters</div>}
30
+ footer={<span>18 records</span>}
31
+ >
32
+ <ul>
33
+ <li>Taxi to client</li>
34
+ <li>Team lunch</li>
35
+ </ul>
36
+ </Section>
37
+ ))
38
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
39
+ })
40
+
41
+ it('has no axe violations when named by label alone', async () => {
42
+ const { container } = render(() => (
43
+ <Section label="Recent activity" headingLevel={3} surface="card">
44
+ <p>Nothing today.</p>
45
+ </Section>
46
+ ))
47
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
48
+ })
49
+ })
@@ -0,0 +1,63 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Section } from './Section'
4
+
5
+ describe('Section (browser, real CSS)', () => {
6
+ it('sits the footer under a real rule', () => {
7
+ const { container } = render(() => (
8
+ <Section title="Ledger" footer={<span>18 records</span>}>
9
+ <div>Body</div>
10
+ </Section>
11
+ ))
12
+ const footer = Array.from(container.querySelectorAll('div')).find(
13
+ (node) => node.textContent === '18 records',
14
+ ) as HTMLElement
15
+ const style = getComputedStyle(footer)
16
+ // "Pagination, totals or selection feedback, under a rule" — the rule is
17
+ // the contract; losing it strands the footer in the body.
18
+ expect(style.borderTopStyle).toBe('solid')
19
+ expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0)
20
+ expect(Number.parseFloat(style.paddingTop)).toBeGreaterThan(0)
21
+ })
22
+
23
+ it('paints the card surface and leaves the plain one bare', () => {
24
+ const card = render(() => (
25
+ <Section surface="card" title="Ledger">
26
+ <div>Body</div>
27
+ </Section>
28
+ ))
29
+ const cardBox = card.container.querySelector(
30
+ '[data-slot="section"]',
31
+ ) as HTMLElement
32
+ const cardStyle = getComputedStyle(cardBox)
33
+ expect(cardStyle.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
34
+ expect(Number.parseFloat(cardStyle.borderRadius)).toBeGreaterThan(0)
35
+ expect(Number.parseFloat(cardStyle.paddingTop)).toBeGreaterThan(0)
36
+ card.unmount()
37
+
38
+ const plain = render(() => (
39
+ <Section title="Ledger">
40
+ <div>Body</div>
41
+ </Section>
42
+ ))
43
+ const plainBox = plain.container.querySelector(
44
+ '[data-slot="section"]',
45
+ ) as HTMLElement
46
+ expect(getComputedStyle(plainBox).backgroundColor).toBe('rgba(0, 0, 0, 0)')
47
+ expect(getComputedStyle(plainBox).paddingTop).toBe('0px')
48
+ })
49
+
50
+ it('renders the heading in its type scale', () => {
51
+ const { container } = render(() => (
52
+ <Section title="Pending expenses">
53
+ <div>Body</div>
54
+ </Section>
55
+ ))
56
+ const heading = container.querySelector('h2') as HTMLElement
57
+ // `text-section` — a dropped scale utility drops the heading into body text.
58
+ expect(
59
+ Number.parseFloat(getComputedStyle(heading).fontSize),
60
+ ).toBeGreaterThan(16)
61
+ expect(heading.getBoundingClientRect().height).toBeGreaterThan(0)
62
+ })
63
+ })
@@ -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,48 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { SelectionBar } from './SelectionBar'
5
+
6
+ const AXE_OPTIONS = {
7
+ rules: {
8
+ 'color-contrast': { enabled: false },
9
+ region: { enabled: false },
10
+ },
11
+ }
12
+
13
+ function summarize(results: AxeResults) {
14
+ return results.violations.map((violation) => ({
15
+ id: violation.id,
16
+ impact: violation.impact,
17
+ targets: violation.nodes.map((node) => node.target),
18
+ }))
19
+ }
20
+
21
+ describe('SelectionBar accessibility', () => {
22
+ it('has no axe violations with a primary action and clear', async () => {
23
+ const { container } = render(() => (
24
+ <SelectionBar
25
+ count={3}
26
+ summary="$14,650"
27
+ primary={{ label: 'Approve expenses', onClick: () => {} }}
28
+ onClear={() => {}}
29
+ />
30
+ ))
31
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
32
+ })
33
+
34
+ it('has no axe violations with a secondary control and no summary', async () => {
35
+ const { container } = render(() => (
36
+ <SelectionBar
37
+ count={12}
38
+ primary={{
39
+ label: 'Archive',
40
+ onClick: () => {},
41
+ disabled: true,
42
+ }}
43
+ secondary={<button type="button">Merge</button>}
44
+ />
45
+ ))
46
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
47
+ })
48
+ })
@@ -0,0 +1,57 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { SelectionBar } from './SelectionBar'
4
+
5
+ describe('SelectionBar (browser, real CSS)', () => {
6
+ it('paints a raised card over the data', () => {
7
+ const { container } = render(() => <SelectionBar count={3} />)
8
+ const bar = container.querySelector(
9
+ '[data-slot="selection-bar"]',
10
+ ) as HTMLElement
11
+ const style = getComputedStyle(bar)
12
+ expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
13
+ // `shadow-sm` is what lifts it off the table it covers.
14
+ expect(style.boxShadow).not.toBe('none')
15
+ expect(Number.parseFloat(style.borderRadius)).toBeGreaterThan(0)
16
+ expect(bar.getBoundingClientRect().height).toBeGreaterThanOrEqual(24)
17
+ })
18
+
19
+ it('sets the count in the medium interface weight', () => {
20
+ const { container } = render(() => (
21
+ <SelectionBar count={3} summary="$14,650" />
22
+ ))
23
+ const count = Array.from(container.querySelectorAll('span')).find(
24
+ (node) => node.textContent === '3 selected',
25
+ ) as HTMLElement
26
+ expect(
27
+ Number.parseInt(getComputedStyle(count).fontWeight, 10),
28
+ ).toBeGreaterThanOrEqual(500)
29
+ // The summary is a quieter line: it must not match the count's colour.
30
+ const summary = Array.from(container.querySelectorAll('span')).find(
31
+ (node) => node.textContent === '$14,650',
32
+ ) as HTMLElement
33
+ expect(getComputedStyle(summary).color).not.toBe(
34
+ getComputedStyle(count).color,
35
+ )
36
+ })
37
+
38
+ it('pins a sticky bar above the bottom edge on the harness phone viewport', () => {
39
+ // This project renders at 414px — below the `sm` breakpoint, which is
40
+ // exactly where `placement="sticky"` becomes `max-sm:fixed`.
41
+ const { container } = render(() => (
42
+ <SelectionBar count={3} placement="sticky" />
43
+ ))
44
+ const bar = container.querySelector(
45
+ '[data-slot="selection-bar"]',
46
+ ) as HTMLElement
47
+ expect(getComputedStyle(bar).position).toBe('fixed')
48
+ expect(Number.parseFloat(getComputedStyle(bar).bottom)).toBeGreaterThan(0)
49
+
50
+ // The inline default stays in flow at the same width.
51
+ const inline = render(() => <SelectionBar count={3} />)
52
+ const inlineBar = inline.container.querySelector(
53
+ '[data-slot="selection-bar"]',
54
+ ) as HTMLElement
55
+ expect(getComputedStyle(inlineBar).position).not.toBe('fixed')
56
+ })
57
+ })
@@ -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}
@@ -0,0 +1,71 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import axe, { type AxeResults } from 'axe-core'
3
+ import { describe, expect, it } from 'vitest'
4
+ import {
5
+ Sidebar,
6
+ SidebarCard,
7
+ SidebarHeader,
8
+ SidebarItem,
9
+ SidebarSearch,
10
+ SidebarSection,
11
+ SidebarTrigger,
12
+ } from './Sidebar'
13
+
14
+ const AXE_OPTIONS = {
15
+ rules: {
16
+ 'color-contrast': { enabled: false },
17
+ region: { enabled: false },
18
+ },
19
+ }
20
+
21
+ function summarize(results: AxeResults) {
22
+ return results.violations.map((violation) => ({
23
+ id: violation.id,
24
+ impact: violation.impact,
25
+ targets: violation.nodes.map((node) => node.target),
26
+ }))
27
+ }
28
+
29
+ describe('Sidebar accessibility', () => {
30
+ it('has no axe violations for an expanded workspace sidebar', async () => {
31
+ const { container } = render(() => (
32
+ <Sidebar ariaLabel="Main">
33
+ <SidebarHeader title="Acme" subtitle="Production" />
34
+ <SidebarTrigger />
35
+ <SidebarSearch placeholder="Filter pages" />
36
+ <SidebarItem route="/inbox" icon="lucide-inbox" active>
37
+ Inbox
38
+ </SidebarItem>
39
+ <SidebarItem route="/drafts" icon="lucide-file-pen">
40
+ Drafts
41
+ </SidebarItem>
42
+ <SidebarSection label="Projects" collapsible>
43
+ <SidebarItem route="/website">Website</SidebarItem>
44
+ <SidebarItem route="/app">App</SidebarItem>
45
+ </SidebarSection>
46
+ <SidebarCard
47
+ title="Trial ends soon"
48
+ description="Upgrade to keep your workspace."
49
+ dismissible
50
+ action={{ label: 'Upgrade' }}
51
+ />
52
+ </Sidebar>
53
+ ))
54
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
55
+ })
56
+
57
+ it('has no axe violations for the collapsed rail of named icons', async () => {
58
+ const { container } = render(() => (
59
+ <Sidebar collapsed ariaLabel="Main">
60
+ <SidebarHeader title="Acme" logo="https://example.com/logo.png" />
61
+ <SidebarItem label="Inbox" route="/inbox" icon="lucide-inbox" active>
62
+ Inbox
63
+ </SidebarItem>
64
+ <SidebarItem label="Drafts" route="/drafts" icon="lucide-file-pen">
65
+ Drafts
66
+ </SidebarItem>
67
+ </Sidebar>
68
+ ))
69
+ expect(summarize(await axe.run(container, AXE_OPTIONS))).toEqual([])
70
+ })
71
+ })