@rtzll/reflow 0.1.0 → 0.3.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.
@@ -730,10 +730,12 @@ html[data-rf-message-transition]::view-transition-old(root) {
730
730
  column-gap: 10px;
731
731
  min-height: 0;
732
732
  overflow: clip;
733
+ /* As in the reference: the step opens its height on the slow spring, and
734
+ its icon and text fade in once that space starts opening. */
735
+ animation: rf-thinking-step-enter 240ms ease-out 80ms both;
733
736
  }
734
737
  .rf-thinking-timeline-row {
735
738
  padding: 6px 8px 6px 0;
736
- animation: rf-thinking-step-enter 240ms ease-out var(--rf-step-delay, 0ms) both;
737
739
  }
738
740
  .rf-thinking-timeline > li {
739
741
  padding-inline-start: 8px;
@@ -748,7 +750,8 @@ html[data-rf-message-transition]::view-transition-old(root) {
748
750
  display: flex;
749
751
  flex-direction: column;
750
752
  align-items: center;
751
- padding-top: 8px;
753
+ /* The connector stops where the step's text ends, as in the reference. */
754
+ padding-block: 8px 6px;
752
755
  color: var(--rf-muted);
753
756
  }
754
757
  .rf-thinking-timeline-icon > svg {
@@ -843,7 +846,6 @@ html[data-rf-message-transition]::view-transition-old(root) {
843
846
  @keyframes rf-thinking-step-enter {
844
847
  from {
845
848
  opacity: 0;
846
- translate: 0 -3px;
847
849
  }
848
850
  }
849
851
 
@@ -1051,18 +1053,36 @@ html[data-rf-message-transition]::view-transition-old(root) {
1051
1053
  color: var(--rf-muted);
1052
1054
  font: var(--rf-text-caption) / var(--rf-leading-caption) var(--rf-font);
1053
1055
  }
1054
- .rf-thinking-step-source,
1056
+ /* As in the reference: a source chip scales up on the moderate spring as it
1057
+ unblurs; an image fades in as it unblurs. */
1058
+ .rf-thinking-step-source {
1059
+ animation:
1060
+ rf-thinking-source-enter var(--rf-spring-moderate) var(--rf-thinking-part-delay, 0ms) both,
1061
+ rf-thinking-part-unblur 120ms ease-out var(--rf-thinking-part-delay, 0ms) both;
1062
+ }
1055
1063
  .rf-thinking-step-image {
1056
- animation: rf-thinking-part-enter 200ms ease-out var(--rf-thinking-part-delay, 0ms) both;
1064
+ animation:
1065
+ rf-thinking-part-fade 200ms ease-out var(--rf-thinking-part-delay, 0ms) both,
1066
+ rf-thinking-part-unblur 150ms ease-out var(--rf-thinking-part-delay, 0ms) both;
1057
1067
  }
1058
- @keyframes rf-thinking-part-enter {
1068
+ @keyframes rf-thinking-source-enter {
1059
1069
  from {
1060
1070
  opacity: 0;
1071
+ scale: 0.85;
1072
+ }
1073
+ }
1074
+ @keyframes rf-thinking-part-fade {
1075
+ from {
1076
+ opacity: 0;
1077
+ }
1078
+ }
1079
+ @keyframes rf-thinking-part-unblur {
1080
+ from {
1061
1081
  filter: blur(4px);
1062
1082
  }
1063
1083
  }
1064
1084
  @media (prefers-reduced-motion: reduce) {
1065
- .rf-thinking-timeline-row,
1085
+ .rf-thinking-step-body,
1066
1086
  .rf-thinking-step-source,
1067
1087
  .rf-thinking-step-image {
1068
1088
  animation: none;
@@ -2,10 +2,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "remix/compone
2
2
  import { Icon } from './icon.js';
3
3
  import { messageTransitionName, transitionMessage } from './message-transition.js';
4
4
  import { on, ref } from 'remix/component';
5
+ import { prefersReducedMotion } from '@remix-run/ui/animation';
5
6
  import { Badge, Button, Textarea } from './basic.js';
6
7
  import { Accordion } from './groups.js';
7
8
  import { QueuedStack } from './queued-stack.js';
8
9
  import { fluidHover } from './interactive.js';
10
+ import { motion } from './systems.js';
9
11
  import { Tooltip } from './overlays.js';
10
12
  export function CarouselDots(handle) {
11
13
  const trackVisibility = ref((element, signal) => {
@@ -23,9 +25,72 @@ export function CarouselDots(handle) {
23
25
  };
24
26
  }
25
27
  export function ChatMessage(handle) {
28
+ // As in the reference, a message glides to its new place when the
29
+ // conversation re-renders around it, such as earlier messages moving up as a
30
+ // new one is sent and the transcript scrolls to it. Its place is read before
31
+ // the update lands and again on the next frame, once the transcript has
32
+ // scrolled. A message still playing its entrance does not glide.
33
+ let element = null;
34
+ let entered = false;
35
+ let glide;
36
+ const track = ref((node, signal) => {
37
+ if (!(node instanceof HTMLElement))
38
+ return;
39
+ element = node;
40
+ entered = !node.hasAttribute('data-animate') || node.getAnimations().length === 0;
41
+ const settle = (event) => {
42
+ if (event.target === node)
43
+ entered = true;
44
+ };
45
+ node.addEventListener('animationend', settle, { signal });
46
+ node.addEventListener('animationcancel', settle, { signal });
47
+ signal.addEventListener('abort', () => {
48
+ glide?.cancel();
49
+ if (element === node)
50
+ element = null;
51
+ }, { once: true });
52
+ });
53
+ function followMove() {
54
+ const node = element;
55
+ if (!node ||
56
+ !entered ||
57
+ !node.isConnected ||
58
+ prefersReducedMotion() ||
59
+ document.documentElement.hasAttribute('data-rf-message-transition'))
60
+ return;
61
+ // Where the message shows now, a glide in flight included.
62
+ const before = node.getBoundingClientRect().top;
63
+ const glideFrom = (shown) => {
64
+ if (element !== node || !node.isConnected)
65
+ return;
66
+ glide?.cancel();
67
+ const offset = shown - node.getBoundingClientRect().top;
68
+ if (Math.abs(offset) < 0.5)
69
+ return;
70
+ glide = node.animate([{ transform: `translateY(${offset}px)` }, { transform: 'none' }], motion.moderate);
71
+ // A starting animation holds its first frame from now, not from its
72
+ // first tick, so the next paint already shows the old place.
73
+ glide.currentTime = 0;
74
+ };
75
+ // Glide as soon as the update lands, so no frame shows the jump. If the
76
+ // transcript scrolls before the next frame, the glide restarts from the
77
+ // same place against the scrolled layout.
78
+ handle.queueTask(() => {
79
+ glideFrom(before);
80
+ const started = glide;
81
+ const shown = node.getBoundingClientRect().top;
82
+ requestAnimationFrame(() => {
83
+ // The message has not been painted elsewhere yet, so a restart is seamless.
84
+ if (glide === started && Math.abs(node.getBoundingClientRect().top - shown) >= 0.5) {
85
+ glideFrom(before);
86
+ }
87
+ });
88
+ });
89
+ }
26
90
  return () => {
27
91
  const { role, label, attachments, children, size, time, actions, animate = true } = handle.props;
28
- return (_jsxs("article", { class: "rf-chat-message", "data-role": role, "data-size": size, "data-animate": animate ? '' : undefined, "aria-label": label ?? (role === 'user' ? 'You' : 'Assistant'), children: [attachments?.length ? (_jsx("ul", { class: "rf-chat-attachments", "aria-label": "Attachments", children: attachments.map((attachment, index) => (_jsxs("li", { class: "rf-chat-thumb", title: attachment.detail ? `${attachment.name} (${attachment.detail})` : undefined, children: [attachment.previewUrl ? (_jsx("img", { src: attachment.previewUrl, alt: attachment.name })) : (_jsx("span", { class: "rf-chat-thumb-glyph", role: "img", "aria-label": attachment.name, children: _jsxs("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", children: [_jsx("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }), _jsx("path", { d: "M14 3v5h5" })] }) })), attachment.url && _jsx("a", { href: attachment.url, "aria-label": attachment.name })] }, index))) })) : null, children != null && children !== '' && (_jsx("div", { class: "rf-chat-body", style: handle.props.transitionId
92
+ followMove();
93
+ return (_jsxs("article", { class: "rf-chat-message", "data-role": role, "data-size": size, "data-animate": animate ? '' : undefined, "aria-label": label ?? (role === 'user' ? 'You' : 'Assistant'), mix: track, children: [attachments?.length ? (_jsx("ul", { class: "rf-chat-attachments", "aria-label": "Attachments", children: attachments.map((attachment, index) => (_jsxs("li", { class: "rf-chat-thumb", title: attachment.detail ? `${attachment.name} (${attachment.detail})` : undefined, children: [attachment.previewUrl ? (_jsx("img", { src: attachment.previewUrl, alt: attachment.name })) : (_jsx("span", { class: "rf-chat-thumb-glyph", role: "img", "aria-label": attachment.name, children: _jsxs("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", children: [_jsx("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }), _jsx("path", { d: "M14 3v5h5" })] }) })), attachment.url && _jsx("a", { href: attachment.url, "aria-label": attachment.name })] }, index))) })) : null, children != null && children !== '' && (_jsx("div", { class: "rf-chat-body", style: handle.props.transitionId
29
94
  ? { viewTransitionName: messageTransitionName(handle.props.transitionId) }
30
95
  : undefined, children: children })), ((role === 'user' && time) || actions) && (_jsxs("div", { class: "rf-chat-meta", children: [role === 'user' && time && _jsx("span", { class: "rf-chat-time", children: time }), actions != null && _jsx("span", { class: "rf-chat-actions", children: actions })] }))] }));
31
96
  };
@@ -428,7 +493,7 @@ export function ThinkingSteps(handle) {
428
493
  const list = children ??
429
494
  (timeline ? (_jsx("ol", { class: "rf-thinking-timeline", children: steps
430
495
  .filter((step) => step.status !== 'pending')
431
- .map((step, index) => (_jsx("li", { "data-status": step.status ?? 'complete', style: { '--rf-step-delay': `${Math.min(index, 6) * 24}ms` }, children: _jsxs("div", { class: "rf-thinking-step-body", children: [_jsx("span", { class: "rf-thinking-timeline-icon", "aria-hidden": "true", children: step.icon ?? _jsx("span", { class: "rf-thinking-timeline-dot" }) }), _jsxs("div", { class: "rf-thinking-timeline-row", children: [_jsxs("span", { class: "rf-thinking-timeline-title", children: [step.title, step.status === 'active' && !step.title.endsWith('…') ? '…' : ''] }), _jsxs("span", { class: "rf-basic-sr-only", children: [" (", step.status ?? 'complete', ")"] }), step.description && (_jsx("span", { class: "rf-thinking-timeline-description", children: step.description })), step.content != null && (_jsx("div", { class: "rf-thinking-timeline-content", children: step.content }))] })] }) }, step.value))) })) : (_jsx(Accordion, { defaultValue: defaultValue, items: steps.map((step) => ({
496
+ .map((step) => (_jsx("li", { "data-status": step.status ?? 'complete', children: _jsxs("div", { class: "rf-thinking-step-body", children: [_jsx("span", { class: "rf-thinking-timeline-icon", "aria-hidden": "true", children: step.icon ?? _jsx("span", { class: "rf-thinking-timeline-dot" }) }), _jsxs("div", { class: "rf-thinking-timeline-row", children: [_jsxs("span", { class: "rf-thinking-timeline-title", children: [step.title, step.status === 'active' && !step.title.endsWith('…') ? '…' : ''] }), _jsxs("span", { class: "rf-basic-sr-only", children: [" (", step.status ?? 'complete', ")"] }), step.description && (_jsx("span", { class: "rf-thinking-timeline-description", children: step.description })), step.content != null && (_jsx("div", { class: "rf-thinking-timeline-content", children: step.content }))] })] }) }, step.value))) })) : (_jsx(Accordion, { defaultValue: defaultValue, items: steps.map((step) => ({
432
497
  ...step,
433
498
  content: step.content,
434
499
  title: (_jsxs("span", { class: "rf-step-title", "data-status": step.status ?? 'pending', children: [_jsx("span", { "aria-hidden": "true", children: step.status === 'complete' ? '✓' : '○' }), step.title, _jsxs("span", { class: "rf-basic-sr-only", children: [" (", step.status ?? 'pending', ")"] })] })),
package/dist/dates.css CHANGED
@@ -31,6 +31,42 @@
31
31
  'opsz' 18;
32
32
  }
33
33
 
34
+ /* With week numbers, the title belongs to the days: it starts past the
35
+ week column. */
36
+ .rf-calendar[data-week-numbers] .rf-calendar-header {
37
+ padding-inline-start: calc(var(--rf-date-cell) * 0.75 + 8px);
38
+ }
39
+
40
+ /* Months side by side, as in the reference: each title centered over its
41
+ days, previous at the far left, next at the far right. */
42
+ .rf-calendar-header[data-centered] {
43
+ position: relative;
44
+ justify-content: center;
45
+ padding-inline: 0;
46
+ }
47
+ .rf-calendar[data-week-numbers] .rf-calendar-header[data-centered] {
48
+ padding-inline-start: calc(var(--rf-date-cell) * 0.75);
49
+ }
50
+ .rf-calendar-header[data-centered] .rf-calendar-months-label {
51
+ margin-inline-end: 0;
52
+ text-align: center;
53
+ }
54
+ .rf-calendar-header[data-centered] .rf-calendar-nav {
55
+ position: absolute;
56
+ inset-block-start: 0;
57
+ }
58
+ .rf-calendar-header[data-centered] .rf-calendar-nav[data-direction='previous'] {
59
+ inset-inline-start: 0;
60
+ }
61
+ .rf-calendar[data-week-numbers]
62
+ .rf-calendar-header[data-centered]
63
+ .rf-calendar-nav[data-direction='previous'] {
64
+ inset-inline-start: calc(var(--rf-date-cell) * 0.75);
65
+ }
66
+ .rf-calendar-header[data-centered] .rf-calendar-nav[data-direction='next'] {
67
+ inset-inline-end: 0;
68
+ }
69
+
34
70
  .rf-calendar-nav,
35
71
  .rf-calendar-day {
36
72
  appearance: none;
@@ -102,6 +138,46 @@
102
138
  gap: 4px;
103
139
  }
104
140
 
141
+ /* Stacked months, as in the reference: one weekday row above them, kept in
142
+ view while a short popup scrolls, 16px between the months, and arrows that
143
+ page up and down. */
144
+ .rf-calendar[data-stacked] .rf-calendar-months {
145
+ gap: 16px;
146
+ }
147
+ .rf-calendar-weekdays {
148
+ position: sticky;
149
+ inset-block-start: 0;
150
+ z-index: 3;
151
+ display: flex;
152
+ margin-block-end: 16px;
153
+ background: inherit;
154
+ color: var(--rf-muted);
155
+ font-size: var(--rf-text-caption, 12px);
156
+ line-height: var(--rf-leading-caption, 16px);
157
+ }
158
+ .rf-calendar-weekdays > span {
159
+ display: grid;
160
+ flex: none;
161
+ place-items: center;
162
+ inline-size: var(--rf-date-cell);
163
+ block-size: calc(var(--rf-date-cell) * 0.8);
164
+ block-size: round(down, calc(var(--rf-date-cell) * 0.8), 1px);
165
+ }
166
+ .rf-calendar-weekdays > .rf-calendar-weekdays-week {
167
+ inline-size: calc(var(--rf-date-cell) * 0.75);
168
+ }
169
+ .rf-calendar[data-stacked] .rf-calendar-grid thead {
170
+ position: absolute;
171
+ inline-size: 1px;
172
+ block-size: 1px;
173
+ overflow: hidden;
174
+ clip-path: inset(50%);
175
+ white-space: nowrap;
176
+ }
177
+ .rf-calendar[data-stacked] .rf-calendar-nav svg {
178
+ rotate: 90deg;
179
+ }
180
+
105
181
  .rf-calendar-grid {
106
182
  border-collapse: separate;
107
183
  border-spacing: 0;
@@ -186,13 +262,42 @@
186
262
  color: var(--rf-bg);
187
263
  }
188
264
 
265
+ /* Picking a range's end, as in the reference: the proposed end is filled
266
+ like a selected day and glides to the pointer, with a faint ring 1px inside
267
+ it that goes solid while pressed. Its number draws above the fill. */
189
268
  .rf-calendar-preview-end {
190
269
  z-index: 1;
191
- box-shadow: inset 0 0 0 1px var(--rf-accent, #6b97ff);
270
+ background: var(--rf-fg);
271
+ }
272
+ .rf-calendar-preview-end::before {
273
+ content: '';
274
+ position: absolute;
275
+ inset: 1px;
276
+ border: 1px solid var(--rf-bg);
277
+ border-radius: max(0px, calc(var(--rf-radius, 8px) - 1px));
278
+ opacity: 0.4;
279
+ transition: opacity 80ms ease;
280
+ }
281
+ .rf-calendar-months:has(.rf-calendar-day[data-rf-selected]:active)
282
+ .rf-calendar-preview-end::before {
283
+ opacity: 1;
284
+ }
285
+ .rf-calendar-day {
286
+ z-index: 2;
287
+ }
288
+ .rf-calendar-day[data-rf-selected] {
289
+ color: var(--rf-bg);
192
290
  }
193
-
194
291
  .rf-calendar-months:not([data-rf-has-selection]) .rf-calendar-day[data-rf-selected] {
195
- box-shadow: inset 0 0 0 1px var(--rf-accent, #6b97ff);
292
+ background: var(--rf-fg);
293
+ }
294
+ /* The same faint ring marks a filled day under the pointer. */
295
+ .rf-calendar-day[data-selected][data-rf-hovered] {
296
+ outline: 1px solid color-mix(in oklab, var(--rf-bg) 40%, transparent);
297
+ outline-offset: -2px;
298
+ }
299
+ .rf-calendar-day[data-selected][data-rf-hovered]:active {
300
+ outline-color: var(--rf-bg);
196
301
  }
197
302
 
198
303
  @media (forced-colors: active) {
package/dist/dates.js CHANGED
@@ -255,7 +255,7 @@ export function Calendar(handle) {
255
255
  }
256
256
  function monthButton(direction, disabled) {
257
257
  const label = direction === 'left' ? 'Previous month' : 'Next month';
258
- return (_jsx(Tooltip, { content: label, side: "bottom", renderTrigger: (trigger) => (_jsx("button", { ...trigger, type: "button", class: "rf-calendar-nav", "data-rf-hover-item": true, "aria-label": label, disabled: disabled, mix: [trigger.mix, on('click', () => moveMonth(direction === 'left' ? -1 : 1))], children: _jsx(DateArrow, { direction: direction }) })) }));
258
+ return (_jsx(Tooltip, { content: label, side: "bottom", renderTrigger: (trigger) => (_jsx("button", { ...trigger, type: "button", class: "rf-calendar-nav", "data-direction": direction === 'left' ? 'previous' : 'next', "data-rf-hover-item": true, "aria-label": label, disabled: disabled, mix: [trigger.mix, on('click', () => moveMonth(direction === 'left' ? -1 : 1))], children: _jsx(DateArrow, { direction: direction }) })) }));
259
259
  }
260
260
  function onDayKey(event, day) {
261
261
  let next;
@@ -300,9 +300,19 @@ export function Calendar(handle) {
300
300
  event.preventDefault();
301
301
  focusDay(next);
302
302
  }
303
- return (_jsxs("section", { class: "rf-calendar", "aria-label": label, "data-orientation": orientation, style: cellSize && Number.isFinite(cellSize) && cellSize > 0
303
+ // As in the reference: side by side, each title is centered over its
304
+ // month, previous sits at the far left and next at the far right. One
305
+ // month, or months stacked, keep the title left and both arrows together
306
+ // on the first month's right.
307
+ const sideBySide = months.length > 1 && orientation !== 'vertical';
308
+ // Stacked months share one weekday row above them, as in the reference;
309
+ // each month's own header stays in its table for screen readers.
310
+ const stacked = months.length > 1 && orientation === 'vertical';
311
+ const previousDisabled = firstMonth <= monthStart(min);
312
+ const nextDisabled = firstMonth >= monthStart(max);
313
+ return (_jsxs("section", { class: "rf-calendar", "aria-label": label, "data-orientation": orientation, "data-week-numbers": showWeekNumbers ? '' : undefined, "data-stacked": stacked ? '' : undefined, style: cellSize && Number.isFinite(cellSize) && cellSize > 0
304
314
  ? { '--rf-date-cell-size': `${cellSize}px` }
305
- : undefined, children: [_jsxs("div", { class: "rf-calendar-months", mix: [
315
+ : undefined, children: [stacked && (_jsxs("div", { class: "rf-calendar-weekdays", "aria-hidden": "true", children: [showWeekNumbers && _jsx("span", { class: "rf-calendar-weekdays-week" }), weekdays.map((weekday) => (_jsx("span", { children: weekday.slice(0, 2) }, weekday)))] })), _jsxs("div", { class: "rf-calendar-months", mix: [
306
316
  gridRef,
307
317
  hover,
308
318
  on('pointerleave', () => {
@@ -318,8 +328,9 @@ export function Calendar(handle) {
318
328
  const weekCount = Math.ceil((firstDayOffset + daysInMonth) / 7);
319
329
  const weeks = Array.from({ length: weekCount }, (_, row) => Array.from({ length: 7 }, (_, column) => addDays(start, row * 7 + column)));
320
330
  const monthIso = iso(month).slice(0, 7);
321
- return (_jsxs("div", { class: "rf-calendar-month", children: [_jsxs("div", { class: "rf-calendar-header", children: [index === 0 && monthButton('left', firstMonth <= monthStart(min)), _jsx("div", { class: "rf-calendar-months-label", "aria-live": index === 0 ? 'polite' : undefined, "aria-atomic": index === 0 ? 'true' : undefined, children: monthLabel.format(month) }), index === months.length - 1 &&
322
- monthButton('right', firstMonth >= monthStart(max))] }), _jsxs("table", { class: "rf-calendar-grid", role: "grid", "aria-label": monthLabel.format(month), children: [_jsx("thead", { children: _jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "col", class: "rf-calendar-week", abbr: "Week number", children: _jsx("span", { class: "rf-basic-sr-only", children: "Week number" }) })), weekdays.map((weekday) => (_jsx("th", { scope: "col", abbr: weekday, children: weekday.slice(0, 2) }, weekday)))] }) }), _jsx("tbody", { children: weeks.map((week) => (_jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "row", class: "rf-calendar-week", children: getWeekNumber(week[0]) })), week.map((day) => {
331
+ return (_jsxs("div", { class: "rf-calendar-month", children: [_jsxs("div", { class: "rf-calendar-header", "data-centered": sideBySide ? '' : undefined, children: [sideBySide && index === 0 && monthButton('left', previousDisabled), _jsx("div", { class: "rf-calendar-months-label", "aria-live": index === 0 ? 'polite' : undefined, "aria-atomic": index === 0 ? 'true' : undefined, children: monthLabel.format(month) }), sideBySide
332
+ ? index === months.length - 1 && monthButton('right', nextDisabled)
333
+ : index === 0 && (_jsxs(_Fragment, { children: [monthButton('left', previousDisabled), monthButton('right', nextDisabled)] }))] }), _jsxs("table", { class: "rf-calendar-grid", role: "grid", "aria-label": monthLabel.format(month), children: [_jsx("thead", { children: _jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "col", class: "rf-calendar-week", abbr: "Week number", children: _jsx("span", { class: "rf-basic-sr-only", children: "Week number" }) })), weekdays.map((weekday) => (_jsx("th", { scope: "col", abbr: weekday, children: weekday.slice(0, 2) }, weekday)))] }) }), _jsx("tbody", { children: weeks.map((week) => (_jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "row", class: "rf-calendar-week", children: getWeekNumber(week[0]) })), week.map((day) => {
323
334
  const dayIso = iso(day);
324
335
  const outside = !dayIso.startsWith(monthIso);
325
336
  if (!dayIso || (months.length > 1 && outside))
package/dist/flows.css CHANGED
@@ -131,7 +131,75 @@
131
131
  flex: 1 1 auto;
132
132
  flex-direction: column;
133
133
  min-block-size: 0;
134
- border-block-start: 1px solid color-mix(in oklab, var(--rf-border) 60%, transparent);
134
+ /* Room for the header hairline, drawn by ::before so it can strengthen.
135
+ The scroll root clips its overflow, so the line cannot sit on a border. */
136
+ padding-block-start: 1px;
137
+ --rf-scroll-divider-size: 48px;
138
+ }
139
+ /* Scroll dividers, as in the reference: the top hairline closes the header
140
+ and strengthens once rows pass under it; the bottom hairline stands while
141
+ rows continue below and goes at the end, which is why the footer draws no
142
+ line of its own. Without a footer the list is the panel's last child and
143
+ that line would double the panel edge, so it is dropped. The lines ride the
144
+ scroll root because the viewport's fade mask would erase them.
145
+ This follows the reference's intent, not its pixels: there the stronger
146
+ line sits on the root's border, which the root's overflow clip hides, so
147
+ its header line never strengthens. Keep the strengthening. */
148
+ .rf-command-scroll::before,
149
+ .rf-command-scroll::after {
150
+ content: '';
151
+ position: absolute;
152
+ z-index: 1;
153
+ inset-inline: 0;
154
+ block-size: 1px;
155
+ pointer-events: none;
156
+ }
157
+ .rf-command-scroll::before {
158
+ inset-block-start: 0;
159
+ background: color-mix(in oklab, var(--rf-border) 60%, transparent);
160
+ }
161
+ .rf-command-scroll::after {
162
+ inset-block-end: 0;
163
+ background: var(--rf-border);
164
+ opacity: 0;
165
+ }
166
+ @keyframes rf-scroll-divider-start {
167
+ to {
168
+ background: var(--rf-border);
169
+ }
170
+ }
171
+ @keyframes rf-scroll-divider-end {
172
+ from {
173
+ opacity: 1;
174
+ }
175
+ to {
176
+ opacity: 0;
177
+ }
178
+ }
179
+ @supports (timeline-scope: --rf-command-scroller) {
180
+ .rf-command-scroll {
181
+ timeline-scope: --rf-command-scroller;
182
+ }
183
+ .rf-command-scroll > .rf-scroll-viewport {
184
+ scroll-timeline-name: --rf-command-scroller;
185
+ }
186
+ /* Keep the timeline separate, as in systems.css: CSS transformers can emit
187
+ a shorthand timeline syntax that browsers do not yet support. */
188
+ .rf-command-scroll::before {
189
+ --rf-scroll-divider-animation: rf-scroll-divider-start 1ms linear both;
190
+ animation: var(--rf-scroll-divider-animation);
191
+ animation-timeline: --rf-command-scroller;
192
+ animation-range: 0 var(--rf-scroll-divider-size);
193
+ }
194
+ .rf-command-scroll::after {
195
+ --rf-scroll-divider-animation: rf-scroll-divider-end 1ms linear both;
196
+ animation: var(--rf-scroll-divider-animation);
197
+ animation-timeline: --rf-command-scroller;
198
+ animation-range: calc(100% - var(--rf-scroll-divider-size)) 100%;
199
+ }
200
+ .rf-command-scroll:last-child::after {
201
+ content: none;
202
+ }
135
203
  }
136
204
  .rf-command-scroll > .rf-scroll-viewport {
137
205
  flex: 1 1 auto;
package/dist/groups.css CHANGED
@@ -243,6 +243,40 @@
243
243
  .rf-choice-group-list[data-orientation='horizontal'] .rf-choice-group-row {
244
244
  border-radius: var(--rf-radius);
245
245
  }
246
+ /* Once hydrated, a vertical group draws its checked fill as moving blocks, one
247
+ per run of checked rows, under the hover fill; the rows draw none. */
248
+ .rf-choice-runs {
249
+ position: absolute;
250
+ z-index: -1;
251
+ inset: 0;
252
+ pointer-events: none;
253
+ }
254
+ .rf-choice-run {
255
+ position: absolute;
256
+ background: var(--rf-active);
257
+ transition:
258
+ top var(--rf-spring-moderate),
259
+ left var(--rf-spring-moderate),
260
+ width var(--rf-spring-moderate),
261
+ height var(--rf-spring-moderate),
262
+ border-radius var(--rf-spring-moderate) var(--rf-corner-delay, 0ms),
263
+ opacity 80ms ease;
264
+ }
265
+ .rf-choice-run[data-instant] {
266
+ transition: none;
267
+ }
268
+ .rf-choice-run[data-leaving] {
269
+ opacity: 0;
270
+ transition: opacity var(--rf-exit-moderate);
271
+ }
272
+ .rf-choice-group-list[data-rf-selection-blocks] > .rf-choice-group-row:has(input:checked) {
273
+ background: transparent;
274
+ }
275
+ @media (prefers-reduced-motion: reduce) {
276
+ .rf-choice-run:not([data-leaving]) {
277
+ transition: opacity 80ms ease;
278
+ }
279
+ }
246
280
  .rf-choice-group-row[aria-disabled='true'] {
247
281
  opacity: 0.45;
248
282
  }
package/dist/groups.js CHANGED
@@ -4,6 +4,7 @@ import * as accordion from '@remix-run/ui/accordion';
4
4
  import { on } from 'remix/component';
5
5
  import { Checkbox } from './basic.js';
6
6
  import { fluidHover } from './interactive.js';
7
+ import { selectionRuns } from './selection-runs.js';
7
8
  export function Accordion(handle) {
8
9
  return () => {
9
10
  const { items, variant = 'grouped', highlight = 'item', headingLevel = 3, children: _children, ...props } = handle.props;
@@ -12,9 +13,16 @@ export function Accordion(handle) {
12
13
  };
13
14
  }
14
15
  export function CheckboxGroup(handle) {
16
+ // The checked fill moves with the selection, as in the reference. A
17
+ // controlled value changes without a change event, so each render resyncs.
18
+ const runs = selectionRuns();
15
19
  return () => {
16
20
  const { options, label, hideLabel, name, disabled, value, defaultValue, onValueChange, orientation = 'vertical', } = handle.props;
21
+ const vertical = orientation !== 'horizontal';
22
+ if (vertical)
23
+ handle.queueTask(runs.sync);
17
24
  return (_jsxs("fieldset", { class: "rf-choice-group", disabled: disabled, children: [_jsx("legend", { class: hideLabel ? 'rf-basic-sr-only' : undefined, children: label }), _jsxs("div", { class: "rf-choice-group-list", "data-orientation": orientation, mix: [
25
+ vertical && runs.mix,
18
26
  fluidHover({
19
27
  axis: orientation === 'horizontal' ? 'x' : 'y',
20
28
  focusSelector: ':has(input:focus-visible)',
@@ -43,7 +51,7 @@ export function CheckboxGroup(handle) {
43
51
  const selected = event.currentTarget.querySelectorAll('input[type="checkbox"]:checked');
44
52
  onValueChange(Array.from(selected, (input) => input.value));
45
53
  }),
46
- ], children: [_jsx("div", { class: "rf-highlight", "aria-hidden": "true", "data-rf-hover-layer": true }), _jsx("div", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true }), options.map((option) => (_jsx("div", { class: "rf-choice-group-row", "data-rf-hover-item": true, "aria-disabled": disabled || option.disabled || undefined, children: _jsx(Checkbox, { name: name, value: option.value, disabled: option.disabled, checked: value?.includes(option.value), defaultChecked: value === undefined ? defaultValue?.includes(option.value) : undefined, label: _jsxs(_Fragment, { children: [option.label, option.description && _jsx("small", { children: option.description })] }) }) }, option.value)))] })] }));
54
+ ], children: [vertical && _jsx("div", { class: "rf-choice-runs", "aria-hidden": "true", "data-rf-selection-runs": true }), _jsx("div", { class: "rf-highlight", "aria-hidden": "true", "data-rf-hover-layer": true }), _jsx("div", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true }), options.map((option) => (_jsx("div", { class: "rf-choice-group-row", "data-rf-hover-item": true, "aria-disabled": disabled || option.disabled || undefined, children: _jsx(Checkbox, { name: name, value: option.value, disabled: option.disabled, checked: value?.includes(option.value), defaultChecked: value === undefined ? defaultValue?.includes(option.value) : undefined, label: _jsxs(_Fragment, { children: [option.label, option.description && _jsx("small", { children: option.description })] }) }) }, option.value)))] })] }));
47
55
  };
48
56
  }
49
57
  export function RadioGroup(handle) {
@@ -281,6 +281,19 @@ export declare const defaultIcons: {
281
281
  }], readonly ['path', {
282
282
  readonly d: 'M12 5v14';
283
283
  }]];
284
+ readonly pause: readonly [readonly ['rect', {
285
+ readonly x: '14';
286
+ readonly y: '3';
287
+ readonly width: '5';
288
+ readonly height: '18';
289
+ readonly rx: '1';
290
+ }], readonly ['rect', {
291
+ readonly x: '5';
292
+ readonly y: '3';
293
+ readonly width: '5';
294
+ readonly height: '18';
295
+ readonly rx: '1';
296
+ }]];
284
297
  readonly 'rotate-ccw': readonly [readonly ['path', {
285
298
  readonly d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8';
286
299
  }], readonly ['path', {
@@ -398,4 +411,17 @@ export declare const defaultIcons: {
398
411
  readonly cy: '19';
399
412
  readonly r: '1';
400
413
  }]];
414
+ readonly 'more-horizontal': readonly [readonly ['circle', {
415
+ readonly cx: '12';
416
+ readonly cy: '12';
417
+ readonly r: '1';
418
+ }], readonly ['circle', {
419
+ readonly cx: '19';
420
+ readonly cy: '12';
421
+ readonly r: '1';
422
+ }], readonly ['circle', {
423
+ readonly cx: '5';
424
+ readonly cy: '12';
425
+ readonly r: '1';
426
+ }]];
401
427
  };
package/dist/icon-data.js CHANGED
@@ -246,6 +246,10 @@ export const defaultIcons = {
246
246
  ['path', { d: 'M5 12h14' }],
247
247
  ['path', { d: 'M12 5v14' }],
248
248
  ],
249
+ pause: [
250
+ ['rect', { x: '14', y: '3', width: '5', height: '18', rx: '1' }],
251
+ ['rect', { x: '5', y: '3', width: '5', height: '18', rx: '1' }],
252
+ ],
249
253
  'rotate-ccw': [
250
254
  ['path', { d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }],
251
255
  ['path', { d: 'M3 3v5h5' }],
@@ -314,4 +318,9 @@ export const defaultIcons = {
314
318
  ['circle', { cx: '12', cy: '5', r: '1' }],
315
319
  ['circle', { cx: '12', cy: '19', r: '1' }],
316
320
  ],
321
+ 'more-horizontal': [
322
+ ['circle', { cx: '12', cy: '12', r: '1' }],
323
+ ['circle', { cx: '19', cy: '12', r: '1' }],
324
+ ['circle', { cx: '5', cy: '12', r: '1' }],
325
+ ],
317
326
  };
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './dates.tsx';
9
9
  export * from './flows.tsx';
10
10
  export * from './range-color.tsx';
11
11
  export * from './color-picker.tsx';
12
+ export * from './breadcrumb.tsx';
12
13
  export * from './systems.ts';
13
14
  export * from './queued-stack.tsx';
14
15
  export * from './scroll-area.tsx';
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from './dates.js';
9
9
  export * from './flows.js';
10
10
  export * from './range-color.js';
11
11
  export * from './color-picker.js';
12
+ export * from './breadcrumb.js';
12
13
  export * from './systems.js';
13
14
  export * from './queued-stack.js';
14
15
  export * from './scroll-area.js';
@@ -1,4 +1,4 @@
1
- import type { Handle, RemixNode } from 'remix/component';
1
+ import type { Handle, MixInput, RemixNode } from 'remix/component';
2
2
  export interface FluidHoverOptions {
3
3
  /** Track the pointer on one axis or across a two-dimensional grid. */
4
4
  axis?: 'x' | 'y' | 'both';
@@ -62,6 +62,8 @@ export interface DropdownProps {
62
62
  /** `bare` renders an unstyled button trigger that takes its look from `triggerClass`, for sidebar rows and actions. */
63
63
  variant?: 'outline' | 'ghost' | 'bare';
64
64
  triggerClass?: string;
65
+ /** Extra behavior on the trigger, such as a Tooltip's trigger props. */
66
+ triggerMix?: MixInput;
65
67
  /** Which side of the trigger the menu opens on. Default bottom. */
66
68
  side?: 'top' | 'bottom';
67
69
  searchable?: boolean;
@@ -73,5 +75,11 @@ export interface DropdownProps {
73
75
  chevron?: boolean;
74
76
  /** Text shown when a search matches nothing. */
75
77
  emptyMessage?: string;
78
+ /** Opens the menu when a mouse rests on the trigger and closes it once the pointer leaves the trigger and the menu. A click pins it open; touch and the keyboard open it as usual. */
79
+ openOnHover?: boolean;
80
+ /** With openOnHover: ms the pointer rests on the trigger before the menu opens. Default 100. */
81
+ delay?: number;
82
+ /** With openOnHover: ms before the menu closes once the pointer has left, long enough to cross the gap to the menu. Default 150. */
83
+ closeDelay?: number;
76
84
  }
77
85
  export declare function Dropdown(handle: Handle<DropdownProps>): () => import("remix/component").RemixElement;