@antadesign/anta 0.3.31 → 0.3.32

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.
@@ -10,6 +10,10 @@ pixels; strings accept CSS lengths. `padding` and `margin` also accept CSS
10
10
  shorthand, such as `padding="8px 16px"` or `margin="0 auto"`. Omission adds no
11
11
  spacing styles. A bare `round` fully rounds the corners.
12
12
 
13
+ Box uses `position: relative` by default. Absolutely positioned descendants
14
+ use it as their containing block. Set `position: static` on the Box if they
15
+ should use another positioned ancestor.
16
+
13
17
  ```tsx
14
18
  <Box round={8} padding={10}><span /></Box>
15
19
  <Box display="flex" round={8} gap={6} padding={10}><span /></Box>
@@ -44,8 +48,10 @@ edges hide content while scrolling. `fade` enables both automatically.
44
48
 
45
49
  ### Fading a clipped edge
46
50
 
47
- `fade` masks edges with hidden content and removes the mask as scrolling reveals
48
- them. `fadeSize` sets its depth. It measures on connection, even off screen.
51
+ `fade` masks edges with hidden content and eases the mask as scrolling reveals
52
+ them. The mask is removed after the last edge fades out, preserving shadows and
53
+ focus rings at rest. `fadeSize` sets its depth. It measures on connection, even
54
+ off screen.
49
55
 
50
56
  ```tsx
51
57
  const TAGS = ['frontend', 'design-system', 'a11y', 'performance']
@@ -64,8 +70,9 @@ const TAGS = ['frontend', 'design-system', 'a11y', 'performance']
64
70
  .fade-demo a-tag { flex: 0 0 auto; }
65
71
  ```
66
72
 
67
- The mask clips to the padding box. It preserves the Box border, shadows, and
68
- focus ring when no edge is hidden.
73
+ The fade affects the padding box. The mask remains through its exit transition,
74
+ then disappears so shadows and focus rings are visible at rest. The easing is
75
+ disabled when reduced motion is requested.
69
76
 
70
77
  To style hidden edges without `fade`, use `observe="edges"` and the
71
78
  `hidden-start-x`, `hidden-end-x`, `hidden-start-y`, or `hidden-end-y` CSS states.
@@ -91,9 +98,14 @@ const TAGS = ['frontend', 'design-system', 'a11y', 'performance']
91
98
  ## Measurements
92
99
 
93
100
  By default, `onMeasureChange` reports one frame after observation starts, then when
94
- the border-box `width` or `height` changes. `changed` contains all fields changed
95
- since the last event; `current` is the full snapshot. Reporting pauses off screen
96
- and resumes with a fresh snapshot when Box returns.
101
+ the border-box `width` or `height` changes. `changed` contains fields
102
+ changed since the last event; `current` is the full snapshot. Reporting pauses
103
+ off screen and resumes with a fresh snapshot when Box returns.
104
+
105
+ Pass `observeOffscreen` when a virtualized layout needs measurement events or
106
+ clipping states while the Box is outside the viewport. It keeps the selected
107
+ observers active. Use `throttle` to limit reads when offscreen content changes
108
+ often.
97
109
 
98
110
  `observe` accepts one of the eight selections below, or a typed array combining
99
111
  them. `observe={['size', 'edges']}` and `observe={['edges', 'size']}` select the
@@ -134,19 +146,35 @@ When no measurement is selected, `onMeasureChange` adds `size`;
134
146
  Passing both handlers observes size and context without enabling scroll events.
135
147
  Without handlers or `fade`, omitting `observe` keeps Box idle.
136
148
 
137
- Set `throttle` to a minimum interval in milliseconds. The first report has no
138
- added delay; subsequent reports include a trailing update with the latest values.
139
- Omit it or pass `0` for frame-based reporting. Negative or non-finite values use
140
- `0`. The interval applies to `onMeasureChange`; context events are not throttled.
141
- Throttling limits event delivery; it does not reduce observer reads.
142
- `edges`, `scroll`, and `fade` continue measuring during scrolling so their
143
- CSS states remain current.
149
+ Set `throttle` to a minimum interval in milliseconds. The initial measurement
150
+ is immediate; later activity gets a trailing read with the latest values. Omit
151
+ it or pass `0` for frame-based updates. Negative or non-finite values use `0`.
152
+ The interval limits measurement reads, CSS state updates, and
153
+ `onMeasureChange` events; context events are not throttled. The
154
+ `box.measurement` getter still reads fresh values on demand.
144
155
 
145
156
  `Tooltip truncatedOnly` reads Box's clipping on demand. It works without
146
157
  `observe` or `onMeasureChange` and does not enable continuous observation.
147
158
 
148
- This readout observes `['size', 'overflow', 'edges', 'scroll']` with `throttle={100}`.
149
- Resize or scroll the Box to update it.
159
+ Pass `includeRectsFor` to add matching descendants to each measurement snapshot.
160
+ `current.rects` contains their border boxes in document order, with `top`,
161
+ `left`, `right`, and `bottom` measured from the Box's top-left border edge, plus
162
+ each match's `width` and `height`.
163
+
164
+ The selector does not start observation or add event triggers. Box reads the
165
+ rects when an existing `observe` selection reports a change. `current.rects`
166
+ always contains the latest array; `changed.rects` appears only when that array
167
+ differs from the previous report. For a fresh read at any time, use
168
+ `box.measurement.rects`.
169
+
170
+ With `observe="scroll"` and no throttle, Box can query, measure, and compare
171
+ every matching descendant on each reported frame. Keep the selector focused or
172
+ set `throttle` when many descendants match. A larger interval also delays the
173
+ fade as the reader reaches an edge.
174
+
175
+ This readout observes `['size', 'overflow', 'edges', 'scroll']` with `throttle={100}`
176
+ and `fade`. It includes the first content row in `rects`. Resize or scroll the
177
+ Box to update the values and edge mask.
150
178
 
151
179
  ```tsx title="measurechange"
152
180
  const [measurement, setMeasurement] = useState<BoxMeasurement | null>(null)
@@ -155,19 +183,25 @@ const [measurement, setMeasurement] = useState<BoxMeasurement | null>(null)
155
183
  round={8}
156
184
  className="measure-probe-box"
157
185
  observe={['size', 'overflow', 'edges', 'scroll']}
186
+ fade
187
+ includeRectsFor=".measure-probe-target"
158
188
  throttle={100}
159
189
  onMeasureChange={(_, { current }) => setMeasurement(current)}
160
190
  >
161
191
  <Text size="small" priority="tertiary">Resize or scroll this Box.</Text>
162
- <div className="measure-probe-wide">wide content, so both axes overflow</div>
192
+ <div className="measure-probe-wide measure-probe-target">wide content, so both axes overflow</div>
163
193
  <div className="measure-probe-wide">and a second line, so the vertical axis does too</div>
164
194
  <div className="measure-probe-wide">and a third</div>
165
195
  </Box>
166
196
 
167
197
  <div className="measure-probe-readout">
168
- {Object.entries(measurement ?? {}).map(([field, value]) => (
169
- <Tag key={field} size="small" label={field} value={String(value)} />
170
- ))}
198
+ {Object.entries(measurement ?? {}).map(([field, value]) => field === 'rects' && Array.isArray(value)
199
+ ? <div key={field} className="measure-probe-rects">
200
+ <Tag size="small" label="rects" value={`${value.length} match${value.length === 1 ? '' : 'es'}`} />
201
+ <pre><code>{JSON.stringify(value, null, 2)}</code></pre>
202
+ </div>
203
+ : <Tag key={field} size="small" label={field} value={String(value)} />
204
+ )}
171
205
  </div>
172
206
  ```
173
207
 
@@ -184,6 +218,8 @@ const [measurement, setMeasurement] = useState<BoxMeasurement | null>(null)
184
218
  }
185
219
  .measure-probe-wide { inline-size: 520px; padding-block: 6px; }
186
220
  .measure-probe-readout { display: flex; flex-wrap: wrap; gap: 6px; }
221
+ .measure-probe-rects { flex-basis: 100%; min-width: 0; }
222
+ .measure-probe-rects pre { margin: 4px 0 0; padding: 8px; border: 1px solid var(--border-4); border-radius: 4px; background: var(--bg-2); white-space: pre-wrap; overflow-wrap: anywhere; }
187
223
  ```
188
224
 
189
225
  See the [measurement fields](#boxmeasurement). Overflow fields map to kebab-case
@@ -284,13 +320,15 @@ const canvasRef = useRef<HTMLCanvasElement>(null)
284
320
  | `fade?` | boolean | — | Fades out every edge that currently hides clipped content, and drops the fade from an edge once the reader scrolls to it. |
285
321
  | `fadeSize?` | number \| string | 24 | Depth of the `fade` gradient. A `number` is pixels; a string is any CSS length. |
286
322
  | `gap?` | number \| string | — | Gap between children, matching the CSS `gap` property. A `number` is pixels; a string is any CSS length or two-value gap (`'1rem'`, `'8px 16px'`). Applies while the Box is a flex or grid container. |
323
+ | `includeRectsFor?` | string | — | CSS selector for descendants whose border boxes are included in `measurement.rects` when Box measures. Coordinates are relative to this Box's top-left border edge; matches are in document order. Box reads and compares matching rects for each measurement event. With `observe="scroll"`, use `throttle` when the selector matches many descendants; it also delays CSS state updates after the initial read. |
287
324
  | `margin?` | number \| string | — | Outer spacing, matching CSS `margin`. Numbers are pixels; strings accept CSS shorthand, `auto`, negative lengths, and custom properties. Omission adds no style. |
288
325
  | `observe?` | 'width' \| 'height' \| 'size' \| 'context' \| 'overflow' \| 'edges' \| 'scroll' \| 'all' \| readonly BoxObservation[] | — | One selection or an array of selections, in any order. `'size'` watches width and height; `'context'` watches rendering context; `'overflow'` watches content dimensions and clipping. `'edges'` reports which edges hide content; `'scroll'` reports offsets, potentially every frame; `'all'` selects everything. Selections are independent: use `['size', 'edges']` to combine them. A measurement handler implies `'size'` when no measurement is selected; a context handler adds `'context'`. Size skips content and scroll observers; overflow adds content observation; hidden edges and scroll add scroll reads. Without handlers or `fade`, omission stays idle. |
326
+ | `observeOffscreen?` | boolean | — | Keeps measurement events and CSS clipping states current outside the viewport. Has no effect unless `observe` or `fade` selects measurement. |
289
327
  | `onContextChange?` | (event, detail) => void | — | Fired after Box's browser and local rendering context changes. `detail` contains the changed fields and a full current snapshot. |
290
- | `onMeasureChange?` | (event, detail) => void | — | Fired when a selected measurement field changes. `detail` contains all fields changed since the last event and a full current snapshot. |
328
+ | `onMeasureChange?` | (event, detail) => void | — | Fired when a selected measurement field changes. `detail.changed` contains fields changed since the previous event; `detail.current` includes the full snapshot with matching rects. Rect changes alone do not trigger an event. |
291
329
  | `padding?` | number \| string | — | Inner spacing, matching CSS `padding`. Numbers are pixels; strings accept CSS shorthand, percentages, and custom properties. Omission adds no style. |
292
330
  | `round?` | boolean \| number \| string | — | Fully-round corners (`border-radius: 999px`, clamped to the box). Pass a `number` (px) or a CSS length string (`'1rem'`) for a custom radius. Omit for square corners. |
293
- | `throttle?` | number | 0 | Minimum interval between measurement events, in milliseconds. The first report has no added delay; a trailing report delivers the latest values. Active observers and CSS clipping states are not throttled. |
331
+ | `throttle?` | number | 0 | Minimum interval between measurements, in milliseconds. The initial measurement runs immediately; a trailing read updates CSS states and delivers the latest event. Descendant rects are read only for emitted events. The `measurement` getter always reads fresh values. |
294
332
 
295
333
  ### BoxMeasurement
296
334
 
@@ -307,6 +345,7 @@ const canvasRef = useRef<HTMLCanvasElement>(null)
307
345
  | `hiddenStartY` | boolean | — | |
308
346
  | `overflowX` | boolean | — | Content exceeds the padding box on this axis, regardless of CSS overflow. |
309
347
  | `overflowY` | boolean | — | |
348
+ | `rects` | BoxRect[] | — | Matches for `includeRectsFor`, in document order. Empty when omitted. |
310
349
  | `scrollableX` | boolean | — | The exceeded content can be scrolled by the reader on this axis. |
311
350
  | `scrollableY` | boolean | — | |
312
351
  | `scrollHeight` | number | — | |
@@ -315,6 +354,19 @@ const canvasRef = useRef<HTMLCanvasElement>(null)
315
354
  | `scrollWidth` | number | — | Full scrollable-content dimensions, matching `scrollWidth` / `scrollHeight`. |
316
355
  | `width` | number | — | Border-box width and height in CSS pixels. |
317
356
 
357
+ ### BoxRect
358
+
359
+ Coordinates and dimensions of a matched descendant's border box.
360
+
361
+ | Field | Type | Default | Description |
362
+ |------|------|---------|-------------|
363
+ | `bottom` | number | — | |
364
+ | `height` | number | — | |
365
+ | `left` | number | — | |
366
+ | `right` | number | — | |
367
+ | `top` | number | — | |
368
+ | `width` | number | — | |
369
+
318
370
  ### BoxContext
319
371
 
320
372
  | Field | Type | Default | Description |
@@ -381,7 +433,10 @@ Use `<a-box>` without JSX. Events are non-bubbling `CustomEvent`s with the same
381
433
  observation. Combine the eight selections with spaces, such as
382
434
  `observe="size edges"` or `observe="size scroll"`, in any order. Repeated tokens
383
435
  have no effect; unknown tokens are ignored. A bare `observe` means `"all"`.
384
- `throttle="100"` limits measurement events to a 100 ms interval.
436
+ `throttle="100"` limits measurement reads, CSS state updates, and measurement
437
+ events to a 100 ms interval.
438
+ Add `observe-offscreen` to keep measurement observation active outside the
439
+ viewport.
385
440
 
386
441
  ```html title="a-box"
387
442
  <a-box display="grid" gap="8px" round="12px" observe="all"
@@ -392,7 +447,9 @@ have no effect; unknown tokens are ignored. A bare `observe` means `"all"`.
392
447
  ```
393
448
 
394
449
  Import `@antadesign/anta/elements/a-box` to register the element. Listen for
395
- `measurechange` and `contextchange` with `addEventListener`.
450
+ `measurechange` and `contextchange` with `addEventListener`. Set
451
+ `include-rects-for=".marker"` to add matching descendant rects to each
452
+ measurement without changing the observation triggers.
396
453
  `box.measurement`, `box.context`, and `box.isTruncated` read values synchronously.
397
454
 
398
455
  ## Styling
@@ -94,6 +94,43 @@ it's a no-op there. Use it where the expander sits in a content column
94
94
  with room to its left (a prose measure); in a full-width / edge-to-edge
95
95
  container the chevron would overhang the edge.
96
96
 
97
+ ## Indicator
98
+
99
+ `indicatorPlacement="end"` puts the disclosure mark after the header actions.
100
+ The mark stays inside the summary button, so selecting it toggles the section.
101
+ Actions remain separate controls. The default `start` placement keeps the
102
+ right-pointing chevron that turns down when open. At the end, it points down
103
+ when closed and up when open.
104
+
105
+ Choose `indicator="triangle"` for a filled arrow or `indicator="plus"` for a
106
+ plus that becomes a minus. The default is `chevron`. The built-in motion follows
107
+ the reduced-motion preference. An unrecognized string or number leaves the
108
+ indicator empty; omit `indicator` to keep the default chevron.
109
+
110
+ ```tsx
111
+ <Expander title="Download report" indicatorPlacement="end"
112
+ actions={<Button size="small" label="Download" />}>
113
+ …
114
+ </Expander>
115
+ <Expander title="Plus and minus" indicator="plus" indicatorPlacement="end">…</Expander>
116
+ ```
117
+
118
+ Pass a decorative node as `indicator` to replace the built-in mark. One node
119
+ rotates with the section. To switch between two visuals, pass `closed` and
120
+ `open` nodes. Neither form adds a focus stop or an accessible name: the title
121
+ names the summary button, and its `aria-expanded` reports the state. Put
122
+ interactive controls in `actions`.
123
+
124
+ ```tsx
125
+ <Expander title="Custom mark" indicatorPlacement="end"
126
+ indicator={<Icon shape="chevron-down" />}>…</Expander>
127
+ <Expander title="Two marks" indicatorPlacement="end"
128
+ indicator={{ closed: <Icon shape="plus" />, open: <Icon shape="minus" /> }}>…</Expander>
129
+ ```
130
+
131
+ On a tertiary end-placement expander, `outdent` still aligns the title and body
132
+ with surrounding content. The indicator stays inside the end edge.
133
+
97
134
  ## Title and level
98
135
 
99
136
  `title` takes a string or any node. For a string, `level` (1–6) applies
@@ -168,10 +205,10 @@ toggle:
168
205
 
169
206
  ## Actions
170
207
 
171
- Pass `actions` to render extra controls — buttons, tags — at the end of
172
- the header row. They sit **outside** the toggle trigger, as its flex
173
- siblings: clicking them never toggles the expander, they're separately
174
- focusable, and screen readers announce them as their own controls.
208
+ Pass `actions` to render extra controls — buttons, tags — after the title.
209
+ They sit **outside** the toggle trigger as its header siblings. Clicking them
210
+ never toggles the expander; they're separately focusable and announced as
211
+ their own controls.
175
212
  (Don't put interactive elements *inside* a custom `title` instead —
176
213
  nesting controls in a button is an accessibility anti-pattern.)
177
214
 
@@ -309,13 +346,15 @@ function Accordion() {
309
346
  | Prop | Type | Default | Description |
310
347
  |------|------|---------|-------------|
311
348
  | `title` | ReactNode | — | Summary (header) content. A string is rendered with the `level` type scale; pass a node (e.g. a `<Title>`) for full control or real heading semantics in the document outline. |
312
- | `actions?` | ReactNode | — | Header actions (e.g. buttons, tags) rendered at the end of the header row, OUTSIDE the toggle trigger — clicking them never toggles, they're separately focusable, and screen readers see them as separate controls. |
349
+ | `actions?` | ReactNode | — | Header actions rendered outside the toggle trigger. With an end indicator, actions sit between the title and indicator. |
313
350
  | `defaultOpen?` | boolean | — | Initial open state for the uncontrolled case. |
314
351
  | `disabled?` | boolean | — | Disables the header: not clickable or focusable, hover affordance off, text dimmed. The open state freezes as-is — disabling an open expander keeps it open. `actions` stay live; disable them separately if needed. |
352
+ | `indicator?` | ReactNode | 'chevron' | Built-in disclosure mark, a decorative node, or separate closed/open visuals. Other primitive values leave the indicator empty. Custom nodes are passive; use `actions` for controls. |
353
+ | `indicatorPlacement?` | 'start' \| 'end' | 'start' | Place the disclosure mark before the title or after the actions. |
315
354
  | `level?` | 1 \| 2 \| 3 \| 4 \| 5 \| 6 | 5 | Heading type scale applied to a string `title` (mirrors `<Title>`'s levels). Visual only — for outline semantics, pass a `<Title>` as the title. |
316
355
  | `onStateChange?` | (event, detail) => void | — | Fired before the open state changes. `event` is the cancelable `statechange` — call `event.preventDefault()` to veto an *uncontrolled* toggle (e.g. confirm before closing). `detail.next` is the requested open state, `detail.prev` the current one (booleans). In controlled mode, apply `detail.next` to `open` to accept, or do nothing to reject. |
317
356
  | `open?` | boolean | — | Controlled open state. When provided, the consumer owns open/close. Selecting the summary requests a change through `onStateChange`; omit `open` for uncontrolled use. |
318
- | `outdent?` | boolean | — | Outdent the chevron into the left gutter so the title and body sit flush with surrounding content (the docs-header layout). Only takes effect with `priority="tertiary"` — on the filled priorities the container edge has to bound the chevron, so it's a no-op there. |
357
+ | `outdent?` | boolean | — | Align the title and body with surrounding content on a tertiary expander. With the default start indicator, the mark hangs in the gutter; an end indicator stays inside its edge. |
319
358
  | `priority?` | 'primary' \| 'secondary' \| 'tertiary' | 'secondary' | Surface emphasis. `secondary` (the default) is a subtle fill; `primary` is a more pronounced card; `tertiary` is transparent (the bare disclosure). |
320
359
  | `round?` | boolean \| number \| string | — | Round corners sized to half the folded (header) height: a pill when folded, and — when expanded — the same corner radius rather than a bigger stadium. The element measures the header, so it tracks custom header content. Pass a `number` (px) or CSS length string for a *fixed* radius that overrides the measurement (and skips it) in both states. |
321
360
  | `tone?` | 'neutral' \| 'brand' \| 'info' \| 'success' \| 'warning' \| 'critical' \| (string & {}) | 'neutral' | Semantic tone, or any literal CSS color (`'#ff1493'`, `'rebeccapurple'`) for a one-off custom tone. Named tones re-point the text and (on filled priorities) the surface to the matching palette; a custom color keeps its hue while lightness/chroma are pinned. `'neutral'` (the default) is the same as omitting it. |
@@ -324,7 +363,8 @@ function Accordion() {
324
363
 
325
364
  Use the web component directly when you are not using React or Preact and a native control does not fit.
326
365
 
327
- Slots hold the title and actions. The details element contains the collapsible body.
366
+ Slots hold the title, actions, and an optional custom indicator. The details
367
+ element contains the collapsible body.
328
368
 
329
369
  ```html
330
370
  <a-expander default-state="open">
@@ -336,6 +376,29 @@ Slots hold the title and actions. The details element contains the collapsible b
336
376
  </a-expander>
337
377
  ```
338
378
 
379
+ Use the `indicator` and `indicator-placement` attributes for built-in marks.
380
+ For custom artwork, assign one element to `slot="indicator"`. Assign two
381
+ elements with `data-when="closed"` and `data-when="open"` when the artwork
382
+ changes between states. Indicator content is decorative and cannot receive focus
383
+ or pointer events.
384
+
385
+ ```html
386
+ <a-expander indicator="triangle" indicator-placement="end">…</a-expander>
387
+
388
+ <a-expander indicator-placement="end">
389
+ <a-expander-summary slot="title">Custom mark</a-expander-summary>
390
+ <a-icon slot="indicator" shape="chevron-down" aria-hidden="true" inert></a-icon>
391
+ <a-expander-details>Content</a-expander-details>
392
+ </a-expander>
393
+
394
+ <a-expander indicator-placement="end">
395
+ <a-expander-summary slot="title">Two marks</a-expander-summary>
396
+ <a-icon slot="indicator" data-when="closed" shape="plus" aria-hidden="true" inert></a-icon>
397
+ <a-icon slot="indicator" data-when="open" shape="minus" aria-hidden="true" inert></a-icon>
398
+ <a-expander-details>Content</a-expander-details>
399
+ </a-expander>
400
+ ```
401
+
339
402
  ### Native HTML details
340
403
 
341
404
  For a simple disclosure, add `data-anta` to native `<details>`. It keeps the
@@ -358,18 +421,18 @@ Reach for the props first: **`tone`** sets the color (any CSS color for a custom
358
421
  tone — it derives the surface in oklch), **`priority`** the fill, **`level`** the
359
422
  heading. Two knobs are worth keeping: **`--expander-tone-source`** (the color a
360
423
  custom `tone` derives from — set it to drive the palette from your own variable) and
361
- **`--expander-gutter`** (the shared left inset that re-insets header + body + chevron
362
- in lockstep; `outdent` sets it to `0`).
424
+ **`--expander-gutter`** (the shared title and body inset; it also positions the
425
+ start indicator). It is 24px at the start, 12px at the end, and `0` with tertiary
426
+ `outdent`.
363
427
 
364
428
  ```tsx
365
429
  <Expander tone="#e0457b" title="Custom" />
366
430
  ```
367
431
 
368
432
  For everything else, `<a-expander>` exposes shadow **parts** — `::part(summary)` (the
369
- header button), `::part(content)` (the body), `::part(actions)` (the actions row) — and
370
- the light-DOM `<a-expander-summary>` / `<a-expander-details>` children, which you style
371
- directly. The header hover / active / focus states and the chevron all live on
372
- `::part(summary)`.
433
+ header button), `::part(indicator)` (the decorative mark), `::part(content)` (the
434
+ body), and `::part(actions)` (the actions row) — and the light-DOM
435
+ `<a-expander-summary>` / `<a-expander-details>` children, which you style directly.
373
436
 
374
437
  Each example keeps its CSS in a `<style>` **inside** its own preview, targeting a demo
375
438
  class on that preview's expander (e.g. `.hide-chevron`) — so the rule you read is
@@ -377,25 +440,19 @@ literally in the preview's DOM, next to the element it styles, never a page-wide
377
440
  stylesheet. The folded recipe under each preview is that same CSS; in your app, swap the
378
441
  demo class for your own selector.
379
442
 
380
- **Hide the chevron.** There's deliberately no prop for it: a region that folds needs a
381
- visible affordance. For a genuine one-off, the chevron is the summary's `::before`, so
382
- remove it — then pull `--expander-gutter` in (it drives the title *and* body inset
383
- together) to tighten the now-empty gutter, and drop your own marker into the title:
443
+ **Hide the indicator.** A disclosure needs a visible affordance. If the title
444
+ provides one, hide the indicator part and tighten the unused gutter:
384
445
 
385
446
  ```css
386
- a-expander.hide-chevron::part(summary)::before { display: none; } /* remove the built-in chevron */
447
+ a-expander.hide-chevron::part(indicator) { display: none; }
387
448
  a-expander.hide-chevron { --expander-gutter: 12px; } /* tighten the inset (default 24px) */
388
449
  ```
389
450
 
390
- **Swap the chevron glyph.** It's a CSS mask, so point `mask-image` at your own SVG; it
391
- keeps its gutter position and the open-state rotation:
451
+ **Change the indicator.** Use the built-in `triangle` or `plus` preset, or
452
+ provide a custom node through `indicator` / `slot="indicator"`:
392
453
 
393
- ```css
394
- /* the chevron is the summary's ::before with a mask-image; swap the glyph for a
395
- solid triangle — it keeps its position and the open-state rotate */
396
- a-expander.swap-chevron::part(summary)::before {
397
- mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 5l11 7-11 7z'/%3E%3C/svg%3E");
398
- }
454
+ ```tsx
455
+ <Expander title="Triangle indicator" indicator="triangle" priority="tertiary">…</Expander>
399
456
  ```
400
457
 
401
458
  **Tint the whole header** on hover / press by styling the summary part (the header
@@ -473,29 +530,13 @@ export function SaveButton() {
473
530
  `}</style>
474
531
  ```
475
532
 
476
- *A chevron as a status indicator* — drop the built-in toggle chevron and put a
477
- **non-interactive** chevron in `actions` instead. The whole header still toggles; the
478
- indicator only reflects state, rotating via `:state(open)`. With the `<Expander>` JSX
479
- wrapper, pass `actions={<Icon shape="chevron-down" />}` (the preview below uses the
480
- equivalent element composition because these docs render statically):
533
+ *Style the indicator by state* with `:state(open)` and `::part(indicator)`. The
534
+ example changes its color without changing the built-in glyph or motion:
481
535
 
482
536
  ```tsx
483
- {/* renders an <a-expander> whose only header control is a non-interactive
484
- chevron in the actions slot; the whole header toggles, the chevron flips */}
485
- <Expander actions={<Icon shape="chevron-down" />} className="indicator" defaultOpen>
486
- Build log
487
- </Expander>
488
-
489
- {/* the JSX wrapper boxes the icon in a layout-neutral span, so target the
490
- [slot="actions"] element */}
537
+ <Expander title="Build log" indicatorPlacement="end" className="indicator" defaultOpen>…</Expander>
491
538
  <style>{`
492
- a-expander.indicator { --expander-gutter: 12px; } /* one var: title + body inset */
493
- a-expander.indicator::part(summary)::before { display: none; } /* drop the built-in chevron */
494
- a-expander.indicator [slot="actions"] {
495
- pointer-events: none;
496
- margin-inline-end: 6px; /* a little more right inset */
497
- transition: transform 150ms ease;
498
- }
499
- a-expander.indicator:state(open) [slot="actions"] { transform: rotate(180deg); } /* points down → up */
539
+ a-expander.indicator::part(indicator) { color: var(--text-4); }
540
+ a-expander.indicator:state(open)::part(indicator) { color: var(--text-1); }
500
541
  `}</style>
501
542
  ```
@@ -28,12 +28,21 @@ is limited. `offset` sets the gap in pixels between the trigger and the menu:
28
28
  <SelectFaceted placement="top-end" offset={8} facets={FACETS} />
29
29
  ```
30
30
 
31
- Open **Metadata** in the first demo to choose key–value tags such as
31
+ Open **Recency** in the first demo, select **Custom range**, then pick **From**
32
+ and **To** from the fields side by side. Each field closes its own calendar after
33
+ a pick while the Recency editor stays open for the second date.
34
+
35
+ Open **Metadata** to choose key–value tags such as
32
36
  **env production** or **region us-east**. Its `renderOption` callback reads
33
37
  custom option fields and renders a `Tag` with `label` and `value`. Search
34
38
  for **production** to see the same tag in global results.
35
39
 
36
40
  ```tsx
41
+ const recencySummary = (v) =>
42
+ 'preset' in v
43
+ ? { today: 'Today', yesterday: 'Yesterday', last14: 'Last 14 days', last30: 'Last 30 days' }[v.preset]
44
+ : v.from && v.to ? `${v.from} → ${v.to}` : 'Custom range'
45
+
37
46
  const FACETS = [
38
47
  { key: 'assignee', label: 'Assignee', kind: 'multiple', filter: true, options: people },
39
48
  { key: 'owner', label: 'Owner', kind: 'single', filter: true, options: people },
@@ -56,6 +65,7 @@ const FACETS = [
56
65
  },
57
66
  { key: 'title', label: 'Title contains', kind: 'text' },
58
67
  { key: 'duration', label: 'Min duration', kind: 'custom', summary: (v) => `≥ ${v.min}s`, render: /* … */ },
68
+ { key: 'recency', label: 'Recency', kind: 'custom', summary: recencySummary, render: /* radio presets and two InputDate fields */ },
59
69
  ]
60
70
 
61
71
  const isSet = (v) => !(v == null || v === '' || (Array.isArray(v) && v.length === 0))
@@ -83,8 +93,7 @@ function Demo() {
83
93
 
84
94
  {active.length > 0 && (
85
95
  <div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
86
- {/* Show one editable control for each active facet. Its label becomes
87
- the control's `leading` prefix. */}
96
+ {/* Show an editable control or summary for each active facet. */}
88
97
  {active.map((facet) => {
89
98
  // Options facets → an editable Select (single shows the trailing check;
90
99
  // both are filterable + have a clearable footer).
@@ -106,7 +115,12 @@ function Demo() {
106
115
  />
107
116
  )
108
117
 
109
- // Free-form text / custom → an editable Input.
118
+ if (facet.key === 'recency') {
119
+ const recency = value.recency
120
+ return <Tag key={facet.key} label="Recency" value={recencySummary(recency)} />
121
+ }
122
+
123
+ // Free-form text / duration → an editable Input.
110
124
  const val = facet.kind === 'text' ? value[facet.key] : value[facet.key]?.min
111
125
  const write = (raw) =>
112
126
  setFacet(facet.key, facet.kind === 'text' ? raw || undefined : raw ? { min: raw } : undefined)
@@ -144,9 +158,9 @@ function Demo() {
144
158
  }
145
159
  ```
146
160
 
147
- The trigger opens the facet menu. The example renders one editable
148
- [Select](./select.md) below it for each active facet. Each Select receives the
149
- facet label through `leading`, which displays it as a prefix.
161
+ The trigger opens the facet menu. The example shows active values beside it.
162
+ Option facets use an editable [Select](./select.md) with the facet label in its
163
+ `leading` prefix. Recency uses a summary tag and is edited in the facet menu.
150
164
 
151
165
  ### Custom option content
152
166
 
@@ -300,7 +314,7 @@ A custom facet stores a `Date` directly. Unlike the value of a `single` or
300
314
  #### A recency filter: presets plus a range
301
315
 
302
316
  This editor combines a `RadioGroup` of presets with a **Custom range** option
303
- that reveals two `InputDate` fields. Its value is either `{ preset }` or
317
+ that reveals two `InputDate` fields side by side. Its value is either `{ preset }` or
304
318
  `{ from, to }`. The `resolveRange` helper converts either shape into the date
305
319
  range used to filter the result set. Each InputDate calendar opens above the
306
320
  facet menu without closing it.
@@ -332,7 +346,7 @@ const recency: SelectFacetCustom<Recency> = {
332
346
  const mode = value == null ? '' : 'preset' in value ? value.preset : 'custom'
333
347
  const range = value && 'from' in value ? value : { from: '', to: '' }
334
348
  return (
335
- <div style={{ display: 'grid', gap: 8 }}>
349
+ <div style={{ display: 'grid', gap: 8, minWidth: 360 }}>
336
350
  <RadioGroup
337
351
  size="small"
338
352
  options={PRESETS} // Today / Yesterday / Last 14 / Last 30 / Custom range
@@ -342,12 +356,12 @@ const recency: SelectFacetCustom<Recency> = {
342
356
  onStateChange={(_e, { next }) => onChange(next === 'custom' ? range : { preset: next })}
343
357
  />
344
358
  {mode === 'custom' && (
345
- <>
359
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8 }}>
346
360
  <InputDate size="small" label="From" value={range.from}
347
361
  onValueChange={(from) => onChange({ from, to: range.to })} />
348
362
  <InputDate size="small" label="To" value={range.to} min={range.from || undefined}
349
363
  onValueChange={(to) => onChange({ from: range.from, to })} />
350
- </>
364
+ </div>
351
365
  )}
352
366
  </div>
353
367
  )