@exegia/corpora-ui 0.25.0 → 0.27.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 (48) hide show
  1. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  2. package/dist-lib/components/blocks/shell/shell-layout.d.ts +1 -1
  3. package/dist-lib/components/blocks/shell/type.d.ts +21 -1
  4. package/dist-lib/components/icons/__tests__/icons.test.d.ts +1 -0
  5. package/dist-lib/components/icons/file-badge-cfm.d.ts +7 -0
  6. package/dist-lib/components/icons/file-badge-corpus.d.ts +7 -0
  7. package/dist-lib/components/icons/file-badge-pdf.d.ts +7 -0
  8. package/dist-lib/components/icons/file-badge-tei.d.ts +7 -0
  9. package/dist-lib/components/icons/file-badge-tf.d.ts +7 -0
  10. package/dist-lib/components/icons/file-badge-txt.d.ts +7 -0
  11. package/dist-lib/components/icons/file-badge-xml.d.ts +7 -0
  12. package/dist-lib/components/icons/file-wordmark-cfm.d.ts +7 -0
  13. package/dist-lib/components/icons/file-wordmark-corpus.d.ts +7 -0
  14. package/dist-lib/components/icons/file-wordmark-pdf.d.ts +7 -0
  15. package/dist-lib/components/icons/file-wordmark-tei.d.ts +7 -0
  16. package/dist-lib/components/icons/file-wordmark-tf.d.ts +7 -0
  17. package/dist-lib/components/icons/file-wordmark-txt.d.ts +7 -0
  18. package/dist-lib/components/icons/file-wordmark-xml.d.ts +7 -0
  19. package/dist-lib/components/icons/index.d.ts +15 -0
  20. package/dist-lib/components/icons/types.d.ts +7 -0
  21. package/dist-lib/index.d.ts +1 -0
  22. package/dist-lib/index.js +9134 -1920
  23. package/dist-lib/index.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +65 -0
  26. package/src/components/blocks/shell/animated-panel-provider.tsx +2 -0
  27. package/src/components/blocks/shell/shell-layout.tsx +28 -16
  28. package/src/components/blocks/shell/type.ts +22 -0
  29. package/src/components/blocks/shell/use-shell-panels.ts +30 -1
  30. package/src/components/icons/README.md +91 -0
  31. package/src/components/icons/__tests__/icons.test.tsx +58 -0
  32. package/src/components/icons/file-badge-cfm.tsx +615 -0
  33. package/src/components/icons/file-badge-corpus.tsx +604 -0
  34. package/src/components/icons/file-badge-pdf.tsx +570 -0
  35. package/src/components/icons/file-badge-tei.tsx +524 -0
  36. package/src/components/icons/file-badge-tf.tsx +620 -0
  37. package/src/components/icons/file-badge-txt.tsx +607 -0
  38. package/src/components/icons/file-badge-xml.tsx +576 -0
  39. package/src/components/icons/file-wordmark-cfm.tsx +424 -0
  40. package/src/components/icons/file-wordmark-corpus.tsx +454 -0
  41. package/src/components/icons/file-wordmark-pdf.tsx +470 -0
  42. package/src/components/icons/file-wordmark-tei.tsx +424 -0
  43. package/src/components/icons/file-wordmark-tf.tsx +406 -0
  44. package/src/components/icons/file-wordmark-txt.tsx +421 -0
  45. package/src/components/icons/file-wordmark-xml.tsx +424 -0
  46. package/src/components/icons/index.ts +16 -0
  47. package/src/components/icons/types.ts +8 -0
  48. package/src/index.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/corpora-ui",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -114,6 +114,46 @@ describe("ShellLayout", () => {
114
114
  expect(rightDrawer("Inspector")).toBeDefined()
115
115
  })
116
116
 
117
+ test("trailing actions stay in the right cluster without a header", () => {
118
+ render(
119
+ <ShellLayout
120
+ panels={PANELS}
121
+ trailing={<button type="button">Upload</button>}
122
+ variant="web"
123
+ />
124
+ )
125
+
126
+ const upload = screen.getByRole("button", { name: "Upload" })
127
+ const cluster = upload.parentElement
128
+ // Pinned by margin, not by flex-filling: the cluster hugs the right edge
129
+ // even with nothing beside it to push against.
130
+ expect(cluster?.className).toContain("ml-auto")
131
+ // The right panel's trigger sits after the actions, at the far edge.
132
+ expect(
133
+ cluster?.contains(screen.getByRole("button", { name: "Toggle panel" }))
134
+ ).toBe(true)
135
+ })
136
+
137
+ test("trailing renders the cluster even with no right panel", () => {
138
+ render(
139
+ <ShellLayout trailing={<button type="button">Upload</button>} variant="web" />
140
+ )
141
+
142
+ expect(screen.getByRole("button", { name: "Upload" })).toBeDefined()
143
+ expect(screen.queryByRole("button", { name: "Toggle panel" })).toBeNull()
144
+ })
145
+
146
+ test("panelComponents renders a side with no static panels entry", () => {
147
+ render(
148
+ <ShellLayout
149
+ panelComponents={{ right: <div>Dynamic body</div> }}
150
+ variant="web"
151
+ />
152
+ )
153
+
154
+ expect(rightDrawer("Secondary panel").textContent).toContain("Dynamic body")
155
+ })
156
+
117
157
  test("the header triggers toggle their own side", async () => {
118
158
  const user = userEvent.setup()
119
159
  render(<ShellLayout panels={PANELS} variant="web" />)
@@ -268,6 +308,12 @@ function HookedShell({
268
308
  <button onClick={() => panels.toggle("right")} type="button">
269
309
  External toggle
270
310
  </button>
311
+ <button
312
+ onClick={() => panels.openPanel("right", <div>Details body</div>)}
313
+ type="button"
314
+ >
315
+ Open details
316
+ </button>
271
317
  {panels.isNarrow && <p>No room for the inspector</p>}
272
318
  </ShellLayout>
273
319
  )
@@ -301,6 +347,25 @@ describe("useShellPanels", () => {
301
347
  expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
302
348
  })
303
349
 
350
+ test("openPanel opens the side with the content it was handed", async () => {
351
+ const user = userEvent.setup()
352
+ render(<HookedShell />)
353
+
354
+ expect(rightDrawer().getAttribute("data-state")).toBe("collapsed")
355
+
356
+ await user.click(screen.getByRole("button", { name: "Open details" }))
357
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
358
+ // The dynamic content replaces the static panels entry for that side…
359
+ expect(rightDrawer().textContent).toContain("Details body")
360
+ expect(rightDrawer().textContent).not.toContain("Inspector body")
361
+
362
+ // …and sticks across a close/reopen instead of reverting.
363
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
364
+ await user.click(screen.getByRole("button", { name: "External toggle" }))
365
+ expect(rightDrawer().getAttribute("data-state")).toBe("expanded")
366
+ expect(rightDrawer().textContent).toContain("Details body")
367
+ })
368
+
304
369
  test("mirrors the shell's too-narrow verdict back into the hook", () => {
305
370
  const restore = shellViewport(800)
306
371
  try {
@@ -21,6 +21,8 @@ export function AnimatedPanelProvider({
21
21
  defaultOpenMobile,
22
22
  onOpenMobileChange,
23
23
  onNarrowChange,
24
+ // ShellLayout renders this; the bare provider only keeps it off the DOM.
25
+ panelComponents: _panelComponents,
24
26
  className,
25
27
  style,
26
28
  ...props
@@ -18,11 +18,18 @@ export function ShellLayout({
18
18
  panels,
19
19
  className,
20
20
  header,
21
+ trailing,
21
22
  defaultOpen,
23
+ panelComponents,
22
24
  ...panelControlProps
23
25
  }: ShellLayoutProps): React.ReactElement {
24
26
  const background: ClassNameValue = `bg-linear-to-tr/increasing from-neutral-200 via-neutral-100 to-stone-200 dark:from-neutral-900 dark:via-neutral-950 dark:to-stone-950`
25
27
 
28
+ // Content pushed through `openPanel(side, component)` wins over the static
29
+ // `panels` entry; a side renders its panel when either supplies content.
30
+ const leftContent = panelComponents?.left ?? panels?.left?.component
31
+ const rightContent = panelComponents?.right ?? panels?.right?.component
32
+
26
33
  // Each panel seeds its own side's initial state (`defaultOpen ?? open`);
27
34
  // an explicit `defaultOpen` record — usually from useShellPanels — wins
28
35
  // per side.
@@ -50,20 +57,20 @@ export function ShellLayout({
50
57
  paddingTop: variant === "desktop" ? TITLE_BAR_HEIGHT : 0,
51
58
  }}
52
59
  >
53
- {panels?.left?.component && (
60
+ {leftContent && (
54
61
  <AnimatedPanel
55
62
  ariaLabel="Primary navigation"
56
63
  collapsible="icon"
57
64
  role="navigation"
58
65
  variant="inset"
59
66
  >
60
- {panels?.left?.component}
67
+ {leftContent}
61
68
  </AnimatedPanel>
62
69
  )}
63
70
 
64
71
  <AnimatedPanelInset>
65
72
  <header className="flex h-12 flex-row! items-center justify-between gap-2 border-b px-2">
66
- {panels?.left?.component && (
73
+ {leftContent && (
67
74
  <div className="flex min-w-0 shrink-0 items-center justify-start gap-2">
68
75
  <AnimatedPanelTrigger side="left">
69
76
  <MotionIcon name="PanelLeft" size={24} animation="press" />
@@ -71,26 +78,31 @@ export function ShellLayout({
71
78
  </div>
72
79
  )}
73
80
  {header && (
74
- <div className="flex w-full flex-1 items-center">{header}</div>
81
+ <div className="flex min-w-0 flex-1 items-center">{header}</div>
75
82
  )}
76
- {panels?.right?.component && (
77
- <div className="flex w-full shrink items-center justify-end gap-2">
78
- <AnimatedPanelTrigger aria-label="Toggle panel" side="right">
79
- <MotionIcon
80
- className="opacity-70"
81
- name="PanelRight"
82
- size={24}
83
- />
84
- </AnimatedPanelTrigger>
83
+ {(trailing || rightContent) && (
84
+ // ml-auto, not flex-fill: the cluster hugs the trailing edge even
85
+ // when there is no header (or left trigger) to push against.
86
+ <div className="ml-auto flex shrink-0 items-center justify-end gap-2">
87
+ {trailing}
88
+ {rightContent && (
89
+ <AnimatedPanelTrigger aria-label="Toggle panel" side="right">
90
+ <MotionIcon
91
+ className="opacity-70"
92
+ name="PanelRight"
93
+ size={24}
94
+ />
95
+ </AnimatedPanelTrigger>
96
+ )}
85
97
  </div>
86
98
  )}
87
99
  </header>
88
100
  <div className="min-h-24 flex-1 overflow-auto">{children}</div>
89
101
  </AnimatedPanelInset>
90
102
 
91
- {panels?.right?.component && (
103
+ {rightContent && (
92
104
  <AnimatedPanel
93
- ariaLabel={panels.right.name ?? "Secondary panel"}
105
+ ariaLabel={panels?.right?.name ?? "Secondary panel"}
94
106
  // Below md the panel is portal led over the page, so it carries the
95
107
  // surface itself; the desktop rail keeps it on the inner panel.
96
108
  className={cn(
@@ -103,7 +115,7 @@ export function ShellLayout({
103
115
  side="right"
104
116
  variant="inset"
105
117
  >
106
- {panels.right.component}
118
+ {rightContent}
107
119
  </AnimatedPanel>
108
120
  )}
109
121
  </AnimatedPanelProvider>
@@ -125,6 +125,11 @@ export type TPanelMap<Side extends TPanelSide = TPanelSide> = Partial<
125
125
  Record<Side, IPanel>
126
126
  >
127
127
 
128
+ /** Dynamic panel content keyed by side — filled by
129
+ * `useShellPanels().openPanel(side, component)` and read by ShellLayout,
130
+ * where a side's entry wins over the static `panels` map's `component`. */
131
+ export type SidebarComponentMap = Partial<Record<SidebarSide, ReactNode>>
132
+
128
133
  /** The open/close surface of the shell's panels, lifted straight from
129
134
  * AnimatedPanelProvider — every prop is keyed by side, there are no
130
135
  * explicit per-side props. `useShellPanels` produces the controlled subset
@@ -139,6 +144,7 @@ export type ShellPanelControlProps = Pick<
139
144
  | "onOpenMobileChange"
140
145
  | "shellId"
141
146
  | "defaultPanelWidth"
147
+ | "panelComponents"
142
148
  >
143
149
 
144
150
  export interface UseShellPanelsOptions {
@@ -189,6 +195,13 @@ export interface ShellPanelControls {
189
195
  * mobile state themselves when the viewport is narrow. Carries the same
190
196
  * `isNarrow` refusal as `setOpen`. */
191
197
  toggle: (side: SidebarSide) => void
198
+ /** Open a side's panel and, when given, swap in the content it shows.
199
+ * The component travels through `providerProps.panelComponents` into
200
+ * ShellLayout, where it wins over the static `panels` entry for that
201
+ * side, and sticks until the next `openPanel(side, component)` replaces
202
+ * it — closing the panel leaves it in place for the exit animation and
203
+ * the next open. Carries the same `isNarrow` refusal as `setOpen`. */
204
+ openPanel: (side: SidebarSide, component?: ReactNode) => void
192
205
  /** Spread onto ShellLayout (or AnimatedPanelProvider directly). */
193
206
  providerProps: ShellPanelControlProps
194
207
  }
@@ -199,6 +212,10 @@ export interface ShellLayoutProps extends ShellPanelControlProps {
199
212
  * the `left` rail and the `right` drawer; other sides are reserved. */
200
213
  panels?: TPanelMap
201
214
  header?: ReactNode
215
+ /** Actions pinned to the header's right edge, rendered just before the
216
+ * right panel's trigger. The cluster hugs the trailing edge no matter
217
+ * which of `header` / the left rail / the right panel are present. */
218
+ trailing?: ReactNode
202
219
  className?: string
203
220
  variant?: "web" | "desktop"
204
221
  }
@@ -249,6 +266,11 @@ export interface AnimatedSidebarProviderProps extends HTMLAttributes<HTMLDivElem
249
266
  /** Initial mobile overlay state — every side starts closed. */
250
267
  defaultOpenMobile?: SidebarOpenState
251
268
  onOpenMobileChange?: (open: boolean, side: SidebarSide) => void
269
+ /** Content pushed into a side's panel by `useShellPanels().openPanel`. It
270
+ * rides along in `providerProps` so the record can be spread on either
271
+ * surface: ShellLayout renders it in that side's panel; the bare provider
272
+ * renders no panels, so it only swallows the prop to keep it off the DOM. */
273
+ panelComponents?: SidebarComponentMap
252
274
  /** Fires when the shell crosses the width a secondary panel needs (rail +
253
275
  * body + panel at their floors). An imperative escape hatch for a consumer
254
276
  * that mounts the provider on its own; anything under `ExegiaProvider` can
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { useCallback, useEffect, useId, useMemo, useState } from "react"
4
+ import type { ReactNode } from "react"
4
5
  import { useAtomValue, useSetAtom } from "jotai"
5
6
 
6
7
  import {
@@ -12,6 +13,7 @@ import {
12
13
  import type {
13
14
  ShellPanelControlProps,
14
15
  ShellPanelControls,
16
+ SidebarComponentMap,
15
17
  SidebarSide,
16
18
  UseShellPanelsOptions,
17
19
  } from "./type"
@@ -104,6 +106,23 @@ export function useShellPanels({
104
106
  [open, setOpen]
105
107
  )
106
108
 
109
+ // The content sticks even when the open is refused (too narrow): it is
110
+ // what the panel shows whenever it next gets to open, not a one-shot.
111
+ const [panelComponents, setPanelComponents] = useState<SidebarComponentMap>(
112
+ {}
113
+ )
114
+ const openPanel = useCallback(
115
+ (side: SidebarSide, component?: ReactNode) => {
116
+ if (component !== undefined) {
117
+ setPanelComponents((prev) =>
118
+ prev[side] === component ? prev : { ...prev, [side]: component }
119
+ )
120
+ }
121
+ setOpen(true, side)
122
+ },
123
+ [setOpen]
124
+ )
125
+
107
126
  const providerProps = useMemo<ShellPanelControlProps>(
108
127
  () => ({
109
128
  shellId,
@@ -112,8 +131,17 @@ export function useShellPanels({
112
131
  onOpenChange: setOpen,
113
132
  openMobile,
114
133
  onOpenMobileChange: setOpenMobile,
134
+ panelComponents,
115
135
  }),
116
- [shellId, defaultPanelWidth, open, openMobile, setOpen, setOpenMobile]
136
+ [
137
+ shellId,
138
+ defaultPanelWidth,
139
+ open,
140
+ openMobile,
141
+ setOpen,
142
+ setOpenMobile,
143
+ panelComponents,
144
+ ]
117
145
  )
118
146
 
119
147
  return {
@@ -126,6 +154,7 @@ export function useShellPanels({
126
154
  setOpen,
127
155
  setOpenMobile,
128
156
  toggle,
157
+ openPanel,
129
158
  providerProps,
130
159
  }
131
160
  }
@@ -0,0 +1,91 @@
1
+ # Corpora file icons
2
+
3
+ Fourteen React SVG components covering seven file formats in two visual families.
4
+ Every icon carries **both** a light and a dark artwork layer and picks one with
5
+ Tailwind's `dark` variant — no props, no context, no JavaScript.
6
+
7
+ Generated from `exegia-prod.sketch` → page `icons`, then simplified (dead defs
8
+ removed, light/dark-identical defs shared, ids stripped). Do not hand-edit the
9
+ `.tsx` files; regenerate them from Sketch instead (see
10
+ [Regenerating](#regenerating)).
11
+
12
+ ## Usage
13
+
14
+ ```tsx
15
+ import { FileBadgeTei, FileWordmarkPdf } from '@/components/icons';
16
+
17
+ <FileBadgeTei /> // 64×64, follows the app's dark class
18
+ <FileBadgeTei size={128} /> // any size
19
+ <FileWordmarkPdf title={null} /> // decorative: aria-hidden, no accessible name
20
+ <FileBadgeTei title="TEI source file" /> // custom accessible name
21
+ <FileBadgeTei className="shrink-0" /> // merged with the internal class
22
+ ```
23
+
24
+ All remaining props are forwarded to the root `<svg>`.
25
+
26
+ ## Components
27
+
28
+ | Format | Badge | Wordmark |
29
+ | --- | --- | --- |
30
+ | TEI | `FileBadgeTei` | `FileWordmarkTei` |
31
+ | XML | `FileBadgeXml` | `FileWordmarkXml` |
32
+ | TXT | `FileBadgeTxt` | `FileWordmarkTxt` |
33
+ | TF | `FileBadgeTf` | `FileWordmarkTf` |
34
+ | CFM | `FileBadgeCfm` | `FileWordmarkCfm` |
35
+ | PDF | `FileBadgePdf` | `FileWordmarkPdf` |
36
+ | Corpus | `FileBadgeCorpus` | `FileWordmarkCorpus` |
37
+
38
+ **Badge** — colour-coded pill overlapping the left edge of the sheet.
39
+ **Wordmark** — `.EXT` set along the bottom of the sheet.
40
+
41
+ ## Theming
42
+
43
+ Each icon renders two `<g data-theme-layer>` groups and shows exactly one via
44
+ Tailwind's `dark` variant: the light layer is `dark:hidden`, the dark layer
45
+ `hidden dark:inline`. Whatever drives `dark:` in the app drives the icons —
46
+ here that is `@custom-variant dark (&:is(.dark *))` in `index.css`, so the
47
+ icons follow the `.dark` class exactly like every other component, with no
48
+ inline stylesheet and no media query of their own.
49
+
50
+ To force a theme on one subtree, scope the class:
51
+
52
+ ```tsx
53
+ <div className="dark">
54
+ <FileBadgeTei /> {/* dark artwork regardless of the app theme */}
55
+ </div>
56
+ ```
57
+
58
+ Because the switch is pure CSS it is SSR-safe and cannot flash the wrong theme
59
+ on hydration. The `data-theme-layer` attributes stay on the groups as styling
60
+ and test hooks.
61
+
62
+ ## Notes
63
+
64
+ - **Text is outlined.** Labels are vector paths, not live `<text>`, so the
65
+ icons do not depend on *TikTok Sans Display* being installed.
66
+ - **IDs are namespaced** per component and per theme layer (`tei-badge-l-…`),
67
+ so gradients and filters never collide when several icons share a page.
68
+ Only referenced defs carry ids; decorative group ids were stripped.
69
+ - **Both layers ship in every component,** but defs that are identical in
70
+ both themes (sheet geometry, badge plates, label glyphs) exist once and are
71
+ referenced from both layers — a `<defs>` entry resolves regardless of which
72
+ layer it sits in, hidden or not. Theme-specific gradients and filters stay
73
+ per layer, so the two themes remain pixel-faithful to Sketch.
74
+ - **Filter regions were widened** to the SVG default (`-50% / 200%`). Sketch
75
+ exports tight regions such as `height="76.2%"` that clip real geometry in
76
+ spec-compliant renderers — the third sheet line disappears without this.
77
+
78
+ ## Regenerating
79
+
80
+ 1. In Sketch, edit the symbols on the `icons` page of `exegia-prod`.
81
+ 2. Re-export each symbol to SVG, and export each label inside an exact-size
82
+ bounding box to PDF (the PDF round-trip is what outlines the type).
83
+ 3. Run the generator, which splices the outlined labels into the SVG, namespaces
84
+ the IDs, widens the filter regions and emits the `.tsx` files.
85
+ 4. Run `bun scripts/simplify-icons.mjs` (from `react/`), which swaps the layer
86
+ switching onto Tailwind's `dark` variant, drops dead and duplicated defs,
87
+ strips unreferenced ids and default-value attributes, and verifies that
88
+ every remaining `url(#…)` / `href="#…"` reference resolves.
89
+
90
+ Colours are driven by the `file/*` swatches in the Sketch document, so a palette
91
+ change there propagates to every symbol before export.
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { render } from "@testing-library/react"
3
+
4
+ import * as icons from ".."
5
+
6
+ const components = Object.entries(icons).filter(
7
+ ([name]) => name.startsWith("FileBadge") || name.startsWith("FileWordmark")
8
+ ) as [string, (props: Record<string, unknown>) => React.ReactElement][]
9
+
10
+ describe("file icons", () => {
11
+ test("exports all fourteen components", () => {
12
+ expect(components).toHaveLength(14)
13
+ })
14
+
15
+ for (const [name, Icon] of components) {
16
+ test(`${name} renders both theme layers on Tailwind's dark variant`, () => {
17
+ const { container } = render(<Icon />)
18
+ const svg = container.querySelector("svg")
19
+ expect(svg?.getAttribute("width")).toBe("64")
20
+ expect(svg?.getAttribute("role")).toBe("img")
21
+
22
+ const light = svg?.querySelector('[data-theme-layer="light"]')
23
+ const dark = svg?.querySelector('[data-theme-layer="dark"]')
24
+ expect(light?.getAttribute("class")).toBe("dark:hidden")
25
+ expect(dark?.getAttribute("class")).toBe("hidden dark:inline")
26
+ // No inline stylesheet — switching belongs to the app's Tailwind build.
27
+ expect(svg?.querySelector("style")).toBeNull()
28
+ })
29
+
30
+ test(`${name}'s internal references all resolve`, () => {
31
+ const { container } = render(<Icon />)
32
+ const svg = container.querySelector("svg")!
33
+ const ids = new Set(
34
+ [...svg.querySelectorAll("[id]")].map((el) => el.getAttribute("id"))
35
+ )
36
+ const refs = [
37
+ ...[...svg.querySelectorAll("use")].map((el) =>
38
+ el.getAttribute("href")?.slice(1)
39
+ ),
40
+ ...[...svg.outerHTML.matchAll(/url\(#([^)]+)\)/g)].map((m) => m[1]),
41
+ ]
42
+ expect(refs.length).toBeGreaterThan(0)
43
+ for (const ref of refs) expect(ids.has(ref ?? "")).toBe(true)
44
+ })
45
+ }
46
+
47
+ test("title becomes the accessible name; null marks decorative", () => {
48
+ const named = render(<icons.FileBadgeTei title="TEI source" />)
49
+ expect(
50
+ named.container.querySelector("svg")?.getAttribute("aria-label")
51
+ ).toBe("TEI source")
52
+
53
+ const decorative = render(<icons.FileWordmarkPdf title={null} />)
54
+ const svg = decorative.container.querySelector("svg")
55
+ expect(svg?.getAttribute("role")).toBe("presentation")
56
+ expect(svg?.getAttribute("aria-hidden")).toBe("true")
57
+ })
58
+ })