@noxlovette/material 0.9.0 → 0.9.1

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.
@@ -27,12 +27,14 @@ const fromTo = (from, to) => Object.fromEntries(Object.entries(to).map(([key, va
27
27
  export const presence = (isOpen, transition, onExitComplete) => (node) => {
28
28
  let controls;
29
29
  let started = false;
30
+ let settleTimer;
30
31
  if (transition.origin)
31
32
  node.style.transformOrigin = transition.origin;
32
33
  $effect(() => {
33
34
  const open = isOpen();
34
35
  untrack(() => {
35
36
  const { hidden, shown, exited = hidden, enter, exit } = transition;
37
+ clearTimeout(settleTimer);
36
38
  if (open) {
37
39
  controls = animate(node, started ? shown : fromTo(hidden, shown), enter);
38
40
  }
@@ -42,11 +44,45 @@ export const presence = (isOpen, transition, onExitComplete) => (node) => {
42
44
  if (controls === current)
43
45
  onExitComplete?.();
44
46
  });
47
+ settleTimer = setTimeout(() => {
48
+ if (controls === current)
49
+ settle(node);
50
+ }, exitDeadline(current));
45
51
  }
46
52
  started = true;
47
53
  });
48
54
  });
49
- return () => controls?.stop();
55
+ return () => {
56
+ clearTimeout(settleTimer);
57
+ controls?.stop();
58
+ };
59
+ };
60
+ /*
61
+ bits-ui unmounts its content only once every animation on it has settled, and `Presence` once
62
+ Motion's exit resolves. On touch devices a closed menu was reported staying mounted, one per
63
+ open, stacking up (#53): some animation on the node never settled. So once the exit has had its
64
+ time, whatever is still running or paused there is finished (or cancelled, if it can't be),
65
+ which settles bits-ui's wait and the exit's promise.
66
+ */
67
+ const EXIT_DEADLINE_MARGIN_MS = 150;
68
+ const EXIT_DEADLINE_FALLBACK_MS = 1000;
69
+ const exitDeadline = (controls) => {
70
+ const seconds = controls.duration;
71
+ return Number.isFinite(seconds) && seconds > 0
72
+ ? seconds * 1000 + EXIT_DEADLINE_MARGIN_MS
73
+ : EXIT_DEADLINE_FALLBACK_MS;
74
+ };
75
+ const settle = (node) => {
76
+ for (const animation of node.getAnimations?.() ?? []) {
77
+ if (animation.playState === 'finished')
78
+ continue;
79
+ try {
80
+ animation.finish();
81
+ }
82
+ catch {
83
+ animation.cancel();
84
+ }
85
+ }
50
86
  };
51
87
  /**
52
88
  * Keeps an element mounted until its exit animation finishes — the Motion replacement for a
@@ -64,7 +64,8 @@ export declare const lateral: (update: () => void | Promise<void>, { target, axi
64
64
  * instead of `lateral` there.
65
65
  *
66
66
  * Not built into `Navbar`/`Rail`: they don't own the content region that changes, so the app wraps
67
- * its own route change in this.
67
+ * its own route change in this. They, the AppBar and a docked Toolbar stay still and on top while
68
+ * it runs; give a fixed floating Toolbar or FAB the `md-vt-persist` class for the same (motion.css).
68
69
  *
69
70
  * The `target` region swaps its box instantly instead of sliding or resizing to the new page's:
70
71
  * that movement would be exactly the spatial connection this pattern avoids, and after a
@@ -2,8 +2,25 @@ import { animateView } from 'motion';
2
2
  import { prefersReducedMotion } from './reducedMotion.js';
3
3
  import { springTokens, springTransition } from './spring.js';
4
4
  const SHARED_AXIS_OFFSET_PX = 30;
5
+ /* While a navigation transition runs, `.md-vt-persist` chrome (Navbar, Rail, AppBar, …) gets
6
+ its own static layer above the page's (motion.css). Counted, since transitions queue. */
7
+ const NAVIGATING = 'md-navigation-transition';
8
+ let navigating = 0;
9
+ const markNavigating = (builder) => {
10
+ const root = document.documentElement;
11
+ navigating++;
12
+ root.classList.add(NAVIGATING);
13
+ new Promise((resolve, reject) => builder.then ? builder.then(resolve, reject) : resolve(undefined))
14
+ .then((animation) => animation?.finished)
15
+ .catch(() => { })
16
+ .finally(() => {
17
+ if (--navigating === 0)
18
+ root.classList.remove(NAVIGATING);
19
+ });
20
+ };
5
21
  const view = (update, target, spring) => {
6
22
  const builder = animateView(update, springTransition(spring));
23
+ markNavigating(builder);
7
24
  return target ? builder.add(target) : builder;
8
25
  };
9
26
  /* Outgoing content clears quickly; incoming content waits a beat so the two never overlap fully.
@@ -117,7 +134,8 @@ export const lateral = (update, { target, axis = 'x', direction = 'forward', spr
117
134
  * instead of `lateral` there.
118
135
  *
119
136
  * Not built into `Navbar`/`Rail`: they don't own the content region that changes, so the app wraps
120
- * its own route change in this.
137
+ * its own route change in this. They, the AppBar and a docked Toolbar stay still and on top while
138
+ * it runs; give a fixed floating Toolbar or FAB the `md-vt-persist` class for the same (motion.css).
121
139
  *
122
140
  * The `target` region swaps its box instantly instead of sliding or resizing to the new page's:
123
141
  * that movement would be exactly the spatial connection this pattern avoids, and after a
@@ -343,7 +343,10 @@ Floating action buttons (FABs) help people take primary actions.
343
343
  surfaceOpen && 'invisible',
344
344
  // In its footprint, pinned to the top trailing corner the close button shares.
345
345
  withMenu &&
346
- neverExpanded && ['col-start-1 row-start-1 self-start justify-self-end', footprint[size]],
346
+ neverExpanded && [
347
+ 'col-start-1 row-start-1 self-start justify-self-end',
348
+ !open && footprint[size]
349
+ ],
347
350
  className
348
351
  )
349
352
  })}
@@ -443,7 +443,8 @@ export declare const fab: import("tailwind-variants").TVReturnType<{
443
443
  };
444
444
  /**
445
445
  * The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
446
- * corners spring to 56dp round in FAB.svelte.
446
+ * corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
447
+ * settles, so the button can't fall back to its content width (#49: a 24×56 pill).
447
448
  */
448
449
  menuOpen: {
449
450
  true: {
@@ -509,7 +510,8 @@ export declare const fab: import("tailwind-variants").TVReturnType<{
509
510
  };
510
511
  /**
511
512
  * The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
512
- * corners spring to 56dp round in FAB.svelte.
513
+ * corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
514
+ * settles, so the button can't fall back to its content width (#49: a 24×56 pill).
513
515
  */
514
516
  menuOpen: {
515
517
  true: {
@@ -575,7 +577,8 @@ export declare const fab: import("tailwind-variants").TVReturnType<{
575
577
  };
576
578
  /**
577
579
  * The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
578
- * corners spring to 56dp round in FAB.svelte.
580
+ * corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
581
+ * settles, so the button can't fall back to its content width (#49: a 24×56 pill).
579
582
  */
580
583
  menuOpen: {
581
584
  true: {
@@ -276,11 +276,12 @@ export const fab = tv({
276
276
  },
277
277
  /**
278
278
  * The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
279
- * corners spring to 56dp round in FAB.svelte.
279
+ * corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
280
+ * settles, so the button can't fall back to its content width (#49: a 24×56 pill).
280
281
  */
281
282
  menuOpen: {
282
283
  true: {
283
- base: 'justify-center px-spacing-0',
284
+ base: 'justify-center px-spacing-0 size-spacing-700 rounded-[1.75rem]',
284
285
  icon: 'size-spacing-250 text-[20px]',
285
286
  labelTrack: 'grid-cols-[0fr]!'
286
287
  },
@@ -2,7 +2,7 @@ import { tv } from '../../../utils/tv.js';
2
2
  export const contextMenu = tv({
3
3
  slots: {
4
4
  content: `
5
- z-layer-popup min-w-48 max-w-sm gap-spacing-50 overflow-y-auto rounded-lg
5
+ z-layer-popup outline-none min-w-48 max-w-sm gap-spacing-50 overflow-y-auto rounded-lg
6
6
  bg-md-sys-color-surface-container-high px-spacing-100 py-spacing-50
7
7
  shadow-elevation-3 ring-md-sys-color-outline/10
8
8
  `,
@@ -10,14 +10,14 @@ export const contextMenu = tv({
10
10
  rounded-sm relative flex w-full cursor-pointer select-none items-center gap-spacing-100
11
11
  px-spacing-150 py-spacing-100 text-left md-sys-typescale-body-large text-md-sys-color-on-surface
12
12
  outline-none transition-colors md-sys-motion-fast-effects
13
- hover:bg-md-sys-color-on-surface/8 data-[highlighted]:bg-md-sys-color-on-surface/8
13
+ data-[highlighted]:bg-md-sys-color-on-surface/8
14
14
  data-[disabled]:cursor-not-allowed data-[disabled]:opacity-38
15
15
  `,
16
16
  subTrigger: `
17
17
  rounded-sm relative flex w-full cursor-pointer select-none items-center gap-spacing-100
18
18
  px-spacing-150 py-spacing-100 text-left md-sys-typescale-body-large text-md-sys-color-on-surface
19
19
  outline-none transition-colors md-sys-motion-fast-effects
20
- hover:bg-md-sys-color-on-surface/8 data-[state=open]:bg-md-sys-color-on-surface/8
20
+ data-[state=open]:bg-md-sys-color-on-surface/8
21
21
  data-[highlighted]:bg-md-sys-color-on-surface/8
22
22
  data-[disabled]:cursor-not-allowed data-[disabled]:opacity-38
23
23
  `,
@@ -36,7 +36,7 @@ export const contextMenu = tv({
36
36
  },
37
37
  color: {
38
38
  error: {
39
- item: 'text-md-sys-color-error hover:bg-md-sys-color-error/8 data-[highlighted]:bg-md-sys-color-error/8',
39
+ item: 'text-md-sys-color-error data-[highlighted]:bg-md-sys-color-error/8',
40
40
  icon: 'text-md-sys-color-error'
41
41
  }
42
42
  }
@@ -20,6 +20,7 @@ Contrast with `MenuItem` (a single interactive row inside the panel) and
20
20
  import { DropdownMenu } from 'bits-ui';
21
21
  import clsx from 'clsx';
22
22
  import Button from '../../buttons/Button.svelte';
23
+ import { menu } from './theme.js';
23
24
  import type { MenuProps } from './types.js';
24
25
 
25
26
  let {
@@ -49,11 +50,7 @@ Contrast with `MenuItem` (a single interactive row inside the panel) and
49
50
  <div {...wrapperProps} class={wrapperProps.class as any}>
50
51
  <div
51
52
  {...props}
52
- class={clsx(
53
- 'bg-md-sys-color-surface-container-high shadow-elevation-3 ring-md-sys-color-outline/10 gap-spacing-50 px-spacing-100 py-spacing-50 max-w-sm min-w-48 overflow-y-auto rounded-lg',
54
- props.class as any,
55
- contentClass
56
- )}
53
+ class={clsx(menu().content(), props.class as any, contentClass)}
57
54
  {@attach presence(() => isOpen, enterExit.scale)}
58
55
  >
59
56
  {@render children()}
@@ -4,12 +4,14 @@ export const menu = tv({
4
4
  content: `
5
5
  bg-md-sys-color-surface-container-high shadow-elevation-3
6
6
  ring-md-sys-color-outline/10 max-w-sm min-w-48 gap-spacing-50
7
- overflow-y-auto rounded-lg px-spacing-100 py-spacing-50
7
+ overflow-y-auto rounded-lg px-spacing-100 py-spacing-50 outline-none
8
8
  `,
9
+ // No `hover:` fill: bits-ui sets `data-highlighted` on mouse hover already,
10
+ // and a `:hover` tint sticks to the last-tapped row on touch screens (#48).
9
11
  item: `
10
12
  rounded-sm h-11 relative flex w-full cursor-pointer items-center gap-spacing-100 px-spacing-150 py-spacing-100
11
13
  md-sys-typescale-body-medium whitespace-nowrap text-md-sys-color-on-surface
12
- hover:bg-md-sys-color-on-surface/8 data-[highlighted]:bg-md-sys-color-on-surface/8
14
+ data-[highlighted]:bg-md-sys-color-on-surface/8
13
15
  focus-visible:outline-2 focus-visible:outline-offset-2
14
16
  focus-visible:outline-md-sys-color-primary
15
17
  data-[disabled]:cursor-not-allowed data-[disabled]:opacity-38
@@ -9,7 +9,8 @@ open a view from a bar of your own, passing that bar as `anchor`.
9
9
  from 24dp to 12dp) with results in a container 2dp below. `layout` takes one per window tier and
10
10
  defaults to full-screen on compact windows, docked from medium up. Switched in CSS.
11
11
  - **Motion.** Opening and closing are an M3 container transform between the bar and the view, on
12
- the `spatial` spring; a crossfade under reduced motion.
12
+ the `spatial` spring; a crossfade under reduced motion. Following a result link closes it at
13
+ once, leaving the route change to the app's own transition.
13
14
  - **Behavior.** A bits-ui `Dialog`: focus stays in the view, the page doesn't scroll, and Esc, the
14
15
  back button or a click outside closes it. The field is a combobox over the `results` listbox:
15
16
  arrow keys move through the `role="option"` items and Enter picks one. Enter with no item
@@ -58,11 +59,19 @@ open a view from a bar of your own, passing that bar as `anchor`.
58
59
  let shown = $state(untrack(() => open));
59
60
  let view = $state<HTMLElement>();
60
61
  let busy = false;
62
+ // Closing because a result link was followed: no transform, and focus doesn't go back to the bar.
63
+ let leaving = false;
64
+
65
+ const detached = (target: HTMLElement | string | undefined) =>
66
+ !target || (typeof target !== 'string' && !target.isConnected);
61
67
 
62
68
  const sync = () => {
63
69
  if (busy || open === shown) return;
64
70
  const opening = open;
65
- if (opening) measure();
71
+ if (opening) {
72
+ leaving = false;
73
+ measure();
74
+ }
66
75
  const update = async () => {
67
76
  // The view takes the bar's place: hidden, the bar isn't left behind in the page's snapshot
68
77
  // (a second, static bar under the morph). Shown again first on close, so focus can return.
@@ -77,8 +86,18 @@ open a view from a bar of your own, passing that bar as `anchor`.
77
86
  busy = false;
78
87
  sync();
79
88
  };
80
- if (!from || !to) update().then(settle, settle);
81
- else containerTransform(update, { from, to }).then(settle, settle);
89
+ // Without both ends on the page (the bar unmounted by a navigation) there is nothing to morph
90
+ // between; a transform that can't start must still close the view, or the Dialog keeps the
91
+ // page locked (#51).
92
+ if ((leaving && !opening) || detached(from) || detached(to)) {
93
+ update().then(settle, settle);
94
+ return;
95
+ }
96
+ try {
97
+ containerTransform(update, { from: from!, to: to! }).then(settle, settle);
98
+ } catch {
99
+ update().then(settle, settle);
100
+ }
82
101
  };
83
102
 
84
103
  $effect(() => {
@@ -185,10 +204,37 @@ open a view from a bar of your own, passing that bar as `anchor`.
185
204
  // Back to the bar's field. The bar opens on click or typing, never on focus, so this is safe.
186
205
  function focusBar(e: Event) {
187
206
  e.preventDefault();
207
+ // Following a result: the page is changing, and focusing a field would raise the keyboard.
208
+ if (leaving) {
209
+ leaving = false;
210
+ return;
211
+ }
188
212
  const field = anchor instanceof HTMLInputElement ? anchor : anchor?.querySelector('input');
189
213
  field?.focus();
190
214
  }
191
215
 
216
+ /*
217
+ Picking a result that is a link navigates away, so the view closes at once instead of morphing
218
+ back into a bar that's about to leave. A morph there would run alongside the route's own view
219
+ transition and, if the bar's page unmounts, never finish (#51). The click still reaches the
220
+ link, since the view unmounts only after this event. New-tab and download clicks keep it open.
221
+ */
222
+ $effect(() => {
223
+ const el = resultsEl;
224
+ if (!el) return;
225
+ const onclick = (e: MouseEvent) => {
226
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
227
+ return;
228
+ const link = (e.target as Element | null)?.closest?.('a[href]');
229
+ if (!(link instanceof HTMLAnchorElement) || !el.contains(link)) return;
230
+ if ((link.target && link.target !== '_self') || link.hasAttribute('download')) return;
231
+ leaving = true;
232
+ open = false;
233
+ };
234
+ el.addEventListener('click', onclick);
235
+ return () => el.removeEventListener('click', onclick);
236
+ });
237
+
192
238
  function clear() {
193
239
  value = '';
194
240
  inputRef?.focus();
@@ -9,7 +9,8 @@ import type { SearchViewProps } from './types.js';
9
9
  * from 24dp to 12dp) with results in a container 2dp below. `layout` takes one per window tier and
10
10
  * defaults to full-screen on compact windows, docked from medium up. Switched in CSS.
11
11
  * - **Motion.** Opening and closing are an M3 container transform between the bar and the view, on
12
- * the `spatial` spring; a crossfade under reduced motion.
12
+ * the `spatial` spring; a crossfade under reduced motion. Following a result link closes it at
13
+ * once, leaving the route change to the app's own transition.
13
14
  * - **Behavior.** A bits-ui `Dialog`: focus stays in the view, the page doesn't scroll, and Esc, the
14
15
  * back button or a click outside closes it. The field is a combobox over the `results` listbox:
15
16
  * arrow keys move through the `role="option"` items and Enter picks one. Enter with no item
@@ -122,7 +122,7 @@ export function appbarSize(size, hasSubtitle) {
122
122
  export const appbar = tv({
123
123
  slots: {
124
124
  // Starts beside a viewport-anchored Rail (styles/components.css), which runs full height.
125
- base: 'fixed top-spacing-0 left-(--md-rail-inset) right-spacing-0 flex flex-col z-layer-bar transition-colors md-sys-motion-effects',
125
+ base: 'md-vt-persist fixed top-spacing-0 left-(--md-rail-inset) right-spacing-0 flex flex-col z-layer-bar transition-colors md-sys-motion-effects',
126
126
  row: 'grid w-full items-center px-spacing-50',
127
127
  childrenRow: 'w-full px-spacing-100 pb-spacing-100',
128
128
  ghost: 'w-full shrink-0 pointer-events-none',
@@ -1,7 +1,7 @@
1
1
  import { tv } from '../../../utils/tv.js';
2
2
  export const navbar = tv({
3
3
  slots: {
4
- base: 'shadow-elevation-2 bg-md-sys-color-surface-container z-layer-bar flex h-20 fixed bottom-spacing-0 w-full left-spacing-0 justify-around py-spacing-150 md:hidden',
4
+ base: 'md-vt-persist shadow-elevation-2 bg-md-sys-color-surface-container z-layer-bar flex h-20 fixed bottom-spacing-0 w-full left-spacing-0 justify-around py-spacing-150 md:hidden',
5
5
  items: 'flex justify-around w-full',
6
6
  fab: 'bottom-24 right-spacing-200 absolute',
7
7
  ghost: 'h-20 w-full shrink-0 md:hidden pointer-events-none'
@@ -34,7 +34,7 @@ export const rail = tv({
34
34
  variants: {
35
35
  anchor: {
36
36
  viewport: {
37
- base: 'fixed top-[var(--rail-top,0px)] bottom-spacing-0 left-spacing-0',
37
+ base: 'md-vt-persist fixed top-[var(--rail-top,0px)] bottom-spacing-0 left-spacing-0',
38
38
  scrim: 'fixed'
39
39
  },
40
40
  parent: {
@@ -33,7 +33,10 @@ export const toolbar = tv({
33
33
  },
34
34
  variant: {
35
35
  floating: { root: 'inline-flex', segment: 'rounded-full shadow-elevation-1' },
36
- docked: { root: 'fixed left-spacing-0 z-layer-bar w-full', segment: 'w-full rounded-none' }
36
+ docked: {
37
+ root: 'md-vt-persist fixed left-spacing-0 z-layer-bar w-full',
38
+ segment: 'w-full rounded-none'
39
+ }
37
40
  },
38
41
  color: {
39
42
  standard: { segment: 'bg-md-sys-color-surface-container' },
@@ -371,3 +371,29 @@
371
371
  background-color: var(--md-container-transform-color, transparent);
372
372
  box-shadow: var(--md-container-transform-shadow, none);
373
373
  }
374
+
375
+ /*
376
+ App chrome stays put through navigation transitions (#50). Motion's `animateView` puts the
377
+ `target` region (or the root) in its own layer, and the whole `::view-transition` tree paints
378
+ over the live page, so a fixed bar outside `main` was covered by `main`'s snapshots and a fixed
379
+ toolbar or FAB inside it faded and scaled with the page. `md-vt-persist` lifts an element
380
+ into its own layer that is drawn above the page's and never animates: the old snapshot is
381
+ hidden and the new one is the live element.
382
+
383
+ Scoped to the navigation helpers (sharedAxis, lateral, fadeThrough), which set
384
+ `md-navigation-transition` on the root while they run, so a container transform (the search
385
+ view) still grows over the Navbar instead of under it. Built into Navbar, viewport-anchored
386
+ Rail, AppBar and docked Toolbar; add the class to an app-positioned floating Toolbar or FAB.
387
+ Browsers without `match-element` fall back to the old behaviour.
388
+ */
389
+ :root.md-navigation-transition .md-vt-persist {
390
+ view-transition-name: match-element;
391
+ view-transition-class: md-persist;
392
+ }
393
+ ::view-transition-group(*.md-persist),
394
+ ::view-transition-new(*.md-persist) {
395
+ animation: none !important;
396
+ }
397
+ ::view-transition-old(*.md-persist) {
398
+ display: none;
399
+ }
@@ -194,7 +194,9 @@
194
194
  :global(input:disabled + .layer-container) > .tint,
195
195
  :global(:disabled) > .tint
196
196
  ) {
197
- @media (hover: hover) {
197
+ /* pointer: fine too — some touch phones report hover: hover, and a
198
+ tapped element keeps :hover until the next tap elsewhere (#48). */
199
+ @media (hover: hover) and (pointer: fine) {
198
200
  &:is(:global(:hover) > .tint, :global(:active) > .tint) {
199
201
  opacity: 0.08;
200
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noxlovette/material",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "A Material Design 3 component library for Svelte, built on Bits UI.",
6
6
  "keywords": [