# Findable

> Text shortened on screen and whole for the browser’s find-in-page: a data-slean-findable attribute and a hidden="until-found" value, opened one at a time.

Page: https://svelte-lean.vercel.app/primitives/findable · Markdown: https://svelte-lean.vercel.app/primitives/findable.md · Index: https://svelte-lean.vercel.app/llms.txt

An id shortened on screen but whole for the browser’s find-in-page: search for the full value and it opens over its short form.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for every data-slean-findable
	// attribute, static or dynamic.
	import '@svelte-lean/styles/findable.css';

	const payments = [
		{ id: 'pay_7f3a9c21e84b40d6a1c5', customer: 'Amara Okafor', amount: '€120.00' },
		{ id: 'pay_2b8e0d47c19a4f35b7e2', customer: 'Jonas Lindqvist', amount: '€48.50' },
		{ id: 'pay_c5d1f8a03e6b492d8f10', customer: 'Mei Tanaka', amount: '€310.25' }
	];
	const short = (id: string) => `…${id.slice(-6)}`;
</script>

<table>
	<thead>
		<tr><th scope="col">Customer</th><th scope="col">Payment</th><th scope="col">Amount</th></tr>
	</thead>
	<tbody>
		{#each payments as payment (payment.id)}
			<tr>
				<td>{payment.customer}</td>
				<!-- No whitespace between the root and its part. -->
				<td class="mono"><span data-slean-findable={short(payment.id)}><span data-slean-part="full" hidden="until-found">{payment.id}</span></span></td>
				<td>{payment.amount}</td>
			</tr>
		{/each}
	</tbody>
</table>

<style>
	table {
		border-collapse: collapse;
	}

	th,
	td {
		padding: 0.25rem 0.75rem;
		text-align: start;
		white-space: nowrap;
	}

	.mono {
		font-family: var(--slean-font-mono);
	}
</style>
```

Search the page (Ctrl+F or Cmd+F) for 7f3a9c21; press Escape and the id folds back. Safari keeps the short forms and does not reach the whole ids.

## Examples

### With copy and hint

```svelte
<script lang="ts">
	// Three attribute primitives on one button: find, copy and hint.
	import '@svelte-lean/styles/copy.css';
	import '@svelte-lean/styles/findable.css';
	import '@svelte-lean/styles/hint.css';

	const refund = 'rfd_91e4b7a2c05d4f8e3b6a';
</script>

<p>
	Refund
	<button
		type="button"
		class="id"
		data-slean-findable="…{refund.slice(-6)}"
		data-slean-copy={refund}
		data-slean-hint={refund}
		data-slean-hint-mono
		data-slean-hint-copied="Copied"
		aria-label="Copy refund {refund}"
		><span data-slean-part="full" hidden="until-found">{refund}</span></button
	>
</p>

<style>
	.id {
		padding: 0 0.25rem;
		border: 0;
		background: none;
		color: inherit;
		font: inherit;
		font-family: var(--slean-font-mono);
	}
</style>
```

Search the page for 91e4b7a2, then click the opened id to copy it. The button’s name carries the whole id for assistive technology.

### Fold event

```svelte
<script lang="ts">
	import '@svelte-lean/styles/findable.css';
	import type { FindEndDetail } from '@svelte-lean/primitives/findable';

	const transfers = ['trf_4c2e9a71d08b45f6a3e1', 'trf_e81b0d5c27a94f3b6d08'];
	let list = $state<HTMLElement>();
	let status = $state('');

	// slean:findend bubbles from the root once its value folded back: find moved on ('moved'),
	// Escape ('escape'), the find bar closed ('closed') or foldFound() ('code').
	$effect(() => {
		const target = list;
		if (!target) return;
		const onfindend = (event: Event) => {
			const { value, reason } = (event as CustomEvent<FindEndDetail>).detail;
			status = `${value} folded (${reason})`;
		};
		target.addEventListener('slean:findend', onfindend);
		return () => target.removeEventListener('slean:findend', onfindend);
	});
</script>

<ul class="transfers" bind:this={list}>
	{#each transfers as id (id)}
		<li><span data-slean-findable="…{id.slice(-6)}"><span data-slean-part="full" hidden="until-found">{id}</span></span></li>
	{/each}
</ul>
<p role="status">{status}</p>

<style>
	.transfers {
		font-family: var(--slean-font-mono);
	}
</style>
```

Search the page for 4c2e9a71, then press Escape: the status line reports the fold and its reason.

### Rows that clip

```svelte
<script lang="ts">
	import '@svelte-lean/styles/findable.css';

	const orders = [
		{ id: 'ord_6a0f3d92b17e4c58a2d4', note: 'Two boxes, leave at the reception desk' },
		{ id: 'ord_d39c71e05a2b48f69c17', note: 'Gift wrap, no invoice in the parcel' }
	];
</script>

<ul class="orders">
	{#each orders as order (order.id)}
		<li class="clip"><span data-slean-findable="…{order.id.slice(-6)}"><span data-slean-part="full" hidden="until-found">{order.id}</span></span> {order.note}</li>
	{/each}
</ul>

<style>
	.orders {
		padding: 0;
		list-style: none;
	}

	.clip {
		max-inline-size: 16rem;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
	}

	/* A row that clips its text lets the found value out while it is open. */
	.clip:has(> :global([data-slean-found])) {
		overflow: visible;
	}
</style>
```

The open value is shifted to stay inside a scroller that would clip it; a row that clips its own text for an ellipsis lets it out while the root carries data-slean-found.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean-findable` attribute; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/findable/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/findable.css';
```

Put the short form in `data-slean-findable` and the whole value in a direct child `<span data-slean-part="full" hidden="until-found">`. Keep whitespace out between the two: the root’s only rendered text is the short form, drawn by `findable.css`, which is required.

Listen for `slean:findend` to close whatever you opened for the match; its `reason` is `moved`, `escape`, `closed` or `code`.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `any element with data-slean-findable="…"` | – | yes | The attribute value is the short form the stylesheet draws. Carries data-slean-found while its value is open. Copy and hint can sit on the same element. |
| full | `<span hidden="until-found">` | `full` | yes | The whole value, text only, as the root’s direct child. Out of the flow; drawn over the short form while open. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Ctrl+F`/`Cmd+F` | the browser’s find bar | Find reaches the whole value, reveals it and scrolls to it |
| `Escape` | on the page | Folds the open value |
| `Escape` | in the find bar | Closes the find bar; the page takes its focus back and the value folds |

## Styling

`findable.css` draws the short form (`::after` with `content: attr(data-slean-findable)`), takes the whole value out of the flow and, once it is open, draws it over the short form with the warning colors, a border and a shadow, starting where the short form starts so the row keeps its height. It never sets `display` on the value, so where `until-found` is unknown the user agent's `[hidden]` rule keeps it hidden. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | margin-inline, padding-inline |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-elevation-3` | `0 4px 12px oklch(0% 0 0 / 0.1), 0 1px 3px oklch(0% 0 0 / 0.08)` | box-shadow |

State selectors the stylesheet targets, all from the platform or ARIA: `[hidden]`.

Variant attributes: `data-slean-findable`; `data-slean-wrap`.

## Controlled integration

`foldFound()` folds the open value from code and dispatches `slean:findend` with reason `code`; `foundRoot()` returns the root whose value is open; `supportsFindReveal()` tells whether this browser searches and reveals `hidden="until-found"`. `shiftInside(box, bounds)` is the pure shift the behavior applies.

`view.ts`

```ts
import { foldFound, foundRoot, supportsFindReveal } from '@svelte-lean/primitives/findable';

// Whether find-in-page can reach the whole values on this browser.
const findable = supportsFindReveal();

// Before a view is replaced: fold the value find opened (dispatches slean:findend, reason 'code').
if (foundRoot()) foldFound();
```

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 1887 B brotli · 2117 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `hidden="until-found"`, `beforematch`, `content: attr()`, `translate`
- **Shared listeners**: beforematch
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `hidden="until-found" + beforematch`

Tier 1. The findable registers one `beforematch` handler with the shared router. On a match it marks the root `data-slean-found`, folds any other open value and starts a session: a capturing `beforematch`, `pointerdown` and `keydown` listener on the document and `focus` and `blur` on the window, removed together when the value folds. It reads layout on the two frames after a match (the computed overflow of the ancestors, the box of each one that clips, the value's box) to shift the value with an inline `translate`; nothing is read while idle or while the value stays open. The browser does not say when its find bar closes; a return of the page's focus with no press on the page is read as that.

Why this implementation

An operator table shows an opaque id by its last characters so the column stays narrow, but the operator searches the page for the whole id they were sent. A title attribute or a hint is not searched by find; a visually hidden copy is, but then the id is found twice and screen readers read both.

The platform has the answer: `hidden="until-found"` content is searched by find-in-page, and on a match the browser fires `beforematch` and reveals it. What it does not do is draw the revealed value where the short form was without moving the row, keep it inside a scrolling table, keep one open at a time, or fold it back when the operator moves on. That is what the behavior adds, through one `beforematch` handler registered with the shared router; the listeners that notice the end of a find exist only while a value is open (`tests/findable.test.ts` keeps one shared listener for a thousand values and adds none while idle).

### The browser owns

- find-in-page: searching the whole value under hidden="until-found", revealing it on a match, the highlight and the scroll to it
- the same reveal for a fragment link to an element inside the value
- the short form: CSS generated content, which find does not search, so the value is counted once

### Svelte Lean owns

- one shared beforematch listener for every findable value on the page; no state per element
- one value open at a time: the next match folds the previous one back under hidden="until-found"
- folding when find moves to another hidden match, on Escape, and when the find bar closes (the page takes its focus back with no press)
- the shift that keeps the open value inside a scroller that would clip it, measured on the two frames after a match
- slean:findend with the value and the reason; findable.css: the short form and the open value drawn over it

Accessibility

- Folded, assistive technology reads the short form: CSS generated content is part of the element’s text. The whole value is not rendered, so it is not in the accessibility tree; open, it is.
- `aria-label` is prohibited on a generic element, so the behavior adds none. Where the whole value must reach assistive technology without find, give it where a name is allowed: `aria-label` on a copy `<button>`, as in the refund example, or a hint with the whole value on a focusable root.
- The behavior never moves focus. Escape on the page folds the open value without being consumed.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `hidden="until-found" and beforematch` | Limited availability: Chrome and Edge 102, Firefox 139; not in Safari as far as we know | The value stays hidden (display: none); the short form shows and find does not reach the whole value |
| `content: attr() on ::after` | Baseline widely available | Not applicable |
| `The translate property` | Baseline widely available since 2022 | The open value is not shifted inside a clipping scroller |

Without JavaScript

Find still searches and reveals the whole value, and the stylesheet draws it over the short form; it stays open until the page reloads and no slean:findend is dispatched.

Server rendering and hydration

Render the markup as it is, including the hidden attribute. Nothing runs on the server or at import.

Before the behavior loads, find reveals a value and the stylesheet draws it, unshifted, and it stays open. The registration attaches no per-element state, so the first match after hydration is handled.

Compatibility

`hidden=until-found` and `beforematch` are supported in Chrome and Edge 102 and Firefox 139, and not in Safari as far as we know; check the current status before relying on it. Where they are missing, the whole value is hidden like any hidden element, the short form shows, find does not reach the whole value and nothing runs. A fragment link to an element inside the value reveals it the same way. There is no find of its own and no fallback copy: a visually hidden copy would be found twice.

Tests and source

- `packages/primitives/tests/findable.test.ts` Vitest: opening, one at a time, Escape, the find bar closing against a click, nested values, the shift, copy and hint on one element, 1000 values and no listener while idle
- `apps/playground/tests/primitives/findable.spec.ts` Playwright: the real reveal through a fragment navigation, the row height, the shift inside a scroller, one open at a time, Escape, the find bar closing, copy and hint on one element, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/findable/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/findable/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/findable/behavior.ts` the behavior definition, foldFound(), shiftInside() and supportsFindReveal()
- `packages/primitives/src/findable/register.ts` the registration module
- `packages/primitives/src/findable/validate.ts` development validation messages
- `docs/adr/0009-attribute-behaviors.md` why an attribute on any element can be the root of a behavior
- `packages/styles/css/findable.css` the optional stylesheet
