@signal9/era-ui 3.13.1 → 3.14.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.
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { Extension } from '@tiptap/core';
11
11
  import { Plugin, PluginKey } from '@tiptap/pm/state';
12
- import { mount, unmount } from 'svelte';
12
+ import { flushSync, mount, unmount } from 'svelte';
13
13
  import Bold from '@lucide/svelte/icons/bold';
14
14
  import Italic from '@lucide/svelte/icons/italic';
15
15
  import Strikethrough from '@lucide/svelte/icons/strikethrough';
@@ -83,15 +83,43 @@ class BubbleMenuView {
83
83
  return;
84
84
  }
85
85
  this.#view.active = Object.fromEntries(buttons.map((b) => [b.mark, this.#editor.isActive(b.mark)]));
86
- // Measuring requires a laid-out box, so unhide before placing.
86
+ // Measuring requires a laid-out box, so unhide before placing. flushSync
87
+ // applies the `active` change above FIRST — measuring a Svelte component
88
+ // before its pending render lands reads the previous frame's box, which is
89
+ // the same trap the slash menu documents.
87
90
  this.#host.style.display = 'block';
88
- placeOverSelection(this.#host, view.coordsAtPos(selection.from), view.coordsAtPos(selection.to));
91
+ flushSync();
92
+ placeOverSelection(this.#host, selectionRect(view));
89
93
  }
90
94
  destroy() {
91
95
  unmount(this.#toolbar);
92
96
  this.#host.remove();
93
97
  }
94
98
  }
99
+ /**
100
+ * The selection's bounding box in viewport coordinates.
101
+ *
102
+ * Prefers the live DOM selection, whose rect already unions every line of a
103
+ * multi-line selection. ProseMirror's coordsAtPos only describes ONE position,
104
+ * so reconstructing a tall selection's box from its two endpoints is what put
105
+ * the toolbar 305px off centre. The endpoint union is kept as a fallback for the
106
+ * case where the DOM selection is unavailable or collapsed to a zero rect (it
107
+ * still beats nothing, and it is exactly right on a single line).
108
+ */
109
+ function selectionRect(view) {
110
+ const dom = window.getSelection();
111
+ if (dom && dom.rangeCount > 0) {
112
+ const rect = dom.getRangeAt(0).getBoundingClientRect();
113
+ if (rect.width > 0 || rect.height > 0)
114
+ return rect;
115
+ }
116
+ const { from, to } = view.state.selection;
117
+ const a = view.coordsAtPos(from);
118
+ const b = view.coordsAtPos(to);
119
+ const left = Math.min(a.left, b.left);
120
+ const top = Math.min(a.top, b.top);
121
+ return new DOMRect(left, top, Math.max(a.right, b.right) - left, Math.max(a.bottom, b.bottom) - top);
122
+ }
95
123
  export const BubbleMenu = Extension.create({
96
124
  name: 'notesBubbleMenu',
97
125
  addProseMirrorPlugins() {
@@ -21,12 +21,18 @@
21
21
  let { items, active, onCommand }: Props = $props();
22
22
  </script>
23
23
 
24
- <Bar size="sm" class="w-max bg-elevated shadow-lg glass-blur">
24
+ <!-- md with xxs buttons, matching the app's other bars. It was sm + size="icon"
25
+ — a 22px bar of 14px buttons around 10px glyphs, the smallest combination in
26
+ the ladder, which is why the formatting controls read as unhittable. -->
27
+ <Bar
28
+ size="md"
29
+ class="w-max gap-(--era-xxs-inset-md) bg-elevated px-(--era-xxs-inset-md) shadow-lg glass-blur"
30
+ >
25
31
  {#each items as item (item.mark)}
26
32
  {@const Icon = item.icon}
27
33
  <Button
28
34
  icon
29
- size="icon"
35
+ size="xxs"
30
36
  active={active[item.mark] ?? false}
31
37
  aria-label={item.label}
32
38
  title={item.label}
@@ -11,11 +11,15 @@
11
11
  export declare function createOverlayHost(): HTMLDivElement;
12
12
  /** Anchor below `rect`, flipping above when the bottom edge would run off. */
13
13
  export declare function placeBelow(host: HTMLElement, rect: DOMRect, gap?: number): void;
14
- /** Centre over a selection, flipping below when there is no room above. */
15
- export declare function placeOverSelection(host: HTMLElement, start: {
16
- top: number;
17
- left: number;
18
- bottom: number;
19
- }, end: {
20
- right: number;
21
- }, gap?: number): void;
14
+ /**
15
+ * Centre over a selection's BOUNDING BOX, flipping below when there is no room
16
+ * above.
17
+ *
18
+ * Takes the whole rect on purpose. This used to take the two endpoint coords and
19
+ * centre on `(start.left + end.right) / 2`, which is only correct while the
20
+ * selection sits on one line: across several lines it averages an x from the
21
+ * FIRST line with an x from the LAST, and the result points at neither. Measured
22
+ * on a 7-line selection, the toolbar landed 305px from the selection's centre —
23
+ * one line was 0px, two lines 4px, so it looked fine until a selection got tall.
24
+ */
25
+ export declare function placeOverSelection(host: HTMLElement, rect: DOMRect, gap?: number): void;
@@ -28,14 +28,24 @@ export function placeBelow(host, rect, gap = EDGE) {
28
28
  host.style.left = `${Math.max(EDGE, left)}px`;
29
29
  host.style.top = `${Math.max(EDGE, top)}px`;
30
30
  }
31
- /** Centre over a selection, flipping below when there is no room above. */
32
- export function placeOverSelection(host, start, end, gap = 6) {
31
+ /**
32
+ * Centre over a selection's BOUNDING BOX, flipping below when there is no room
33
+ * above.
34
+ *
35
+ * Takes the whole rect on purpose. This used to take the two endpoint coords and
36
+ * centre on `(start.left + end.right) / 2`, which is only correct while the
37
+ * selection sits on one line: across several lines it averages an x from the
38
+ * FIRST line with an x from the LAST, and the result points at neither. Measured
39
+ * on a 7-line selection, the toolbar landed 305px from the selection's centre —
40
+ * one line was 0px, two lines 4px, so it looked fine until a selection got tall.
41
+ */
42
+ export function placeOverSelection(host, rect, gap = 6) {
33
43
  const box = host.getBoundingClientRect();
34
- const centerX = (start.left + end.right) / 2;
44
+ const centerX = rect.left + rect.width / 2;
35
45
  const left = clamp(centerX - box.width / 2, EDGE, window.innerWidth - box.width - EDGE);
36
- let top = start.top - box.height - gap;
46
+ let top = rect.top - box.height - gap;
37
47
  if (top < EDGE)
38
- top = start.bottom + gap;
48
+ top = rect.bottom + gap;
39
49
  host.style.left = `${Math.max(EDGE, left)}px`;
40
50
  host.style.top = `${Math.max(EDGE, top)}px`;
41
51
  }
@@ -158,16 +158,10 @@
158
158
  size="md"
159
159
  class="shrink-0 gap-(--era-xxs-inset-md) rounded-none border-b border-divider-faded px-(--era-xxs-inset-md)"
160
160
  >
161
- <!-- The glyph is on the ICON tier but every other child of this bar is on
162
- the xxs tier, so a bare svg here gets the bar's 3px side padding
163
- against its own ~6px vertical inset. The holder puts it on the same
164
- tier as the new-note button — one even gap for every child — while
165
- the glyph itself stays icon-sized inside it. -->
166
- <span class="flex size-xxs shrink-0 items-center justify-center">
167
- <Search class="size-(--era-icon-xxs) text-muted" />
168
- </span>
169
- <input
170
- class="h-full min-w-0 flex-1 bg-transparent text-body text-fg outline-none placeholder:text-muted"
161
+ <Input
162
+ icon={Search}
163
+ bare
164
+ class="min-w-0 flex-1"
171
165
  type="text"
172
166
  placeholder="Filter…"
173
167
  aria-label="Filter notes"
@@ -329,11 +323,9 @@
329
323
  </Popover.Content>
330
324
  </Popover.Root>
331
325
 
332
- <!-- Chromeless, like the filter input: an h-md Input inside an h-md bar
333
- fills it edge to edge, so the well would draw a box with no room
334
- around it. The bar is the container. -->
335
326
  <Input
336
- class="h-full min-w-0 flex-1 bg-transparent px-0 shadow-none"
327
+ bare
328
+ class="min-w-0 flex-1"
337
329
  aria-label="Note title"
338
330
  placeholder="Untitled"
339
331
  value={selected.title}
@@ -15,4 +15,6 @@ Inherits all props from `HTMLInputAttributes`.
15
15
  | Prop | Type | Default | Notes |
16
16
  |------|------|---------|-------|
17
17
  | `ref?` | `HTMLInputElement \| null` | `null` | bindable |
18
+ | `icon?` | `Component<IconProps> \| null` | `null` | Lucide glyph rendered inside the field, left of the text. |
19
+ | `bare?` | `boolean` | `false` | Drop the field chrome — for an input inside a container that is already the field. |
18
20
  | `value?` | `(forwarded)` | `''` | bindable |
@@ -2753,6 +2753,8 @@ Inherits all props from `HTMLInputAttributes`.
2753
2753
  | Prop | Type | Default | Notes |
2754
2754
  |------|------|---------|-------|
2755
2755
  | `ref?` | `HTMLInputElement \| null` | `null` | bindable |
2756
+ | `icon?` | `Component<IconProps> \| null` | `null` | Lucide glyph rendered inside the field, left of the text. |
2757
+ | `bare?` | `boolean` | `false` | Drop the field chrome — for an input inside a container that is already the field. |
2756
2758
  | `value?` | `(forwarded)` | `''` | bindable |
2757
2759
 
2758
2760
  <!-- end: input -->
@@ -631,7 +631,7 @@
631
631
  "slug": "input",
632
632
  "title": "Input",
633
633
  "summary": "A text input field, with textarea variant.",
634
- "tokenEstimate": 87,
634
+ "tokenEstimate": 145,
635
635
  "sections": [
636
636
  "Import",
637
637
  "Props"
@@ -1,27 +1,86 @@
1
+ <script lang="ts" module>
2
+ import { cn, tv } from '../../utils/index.js';
3
+
4
+ /**
5
+ * The field shell — the chrome an input paints, minus the text styling.
6
+ *
7
+ * Split out as a variant because an `icon` input has to move that chrome onto
8
+ * a WRAPPER (the glyph and the <input> sit inside one field), while a plain
9
+ * one keeps it on the <input> itself. Both spellings therefore have to
10
+ * describe the same box, and a variant is how they stay one description.
11
+ */
12
+ export const inputVariants = tv({
13
+ base: 'flex w-full era-interactive text-body text-fg',
14
+ variants: {
15
+ /**
16
+ * `bare` drops the field chrome entirely — no fill, no well, no side
17
+ * padding. For an input nested in a container that is ALREADY the field:
18
+ * a Bar acting as a toolbar, a header row. Without it the input draws a
19
+ * second box inside the first, and at the same tier it fills its parent
20
+ * edge to edge so the two have no gap between them.
21
+ */
22
+ bare: {
23
+ false:
24
+ 'h-(--era-h-md) rounded-(--era-rd-md) bg-(--era-surface-bg-elevated) px-(--era-field-px) shadow-(--era-shadow-well) hover:bg-(--era-highlight) focus-within:bg-(--era-highlight) focus:bg-(--era-highlight)',
25
+ // h-full, NOT the md tier: a bare input's container IS the field, so it
26
+ // takes that container's height. Keeping h-md here made a bare input
27
+ // inside an h-md Bar overflow it by the bar's border — a −0.5px gap,
28
+ // which is the even-gap law failing by a hair rather than obviously.
29
+ // Implies a parent with a definite height, which is the only place
30
+ // `bare` makes sense anyway.
31
+ true: 'h-full bg-transparent px-0 shadow-none'
32
+ }
33
+ },
34
+ defaultVariants: { bare: false }
35
+ });
36
+ </script>
37
+
1
38
  <script lang="ts">
39
+ import type { Component } from 'svelte';
40
+ import type { IconProps } from '@lucide/svelte';
2
41
  import type { HTMLInputAttributes } from 'svelte/elements';
3
- import { cn } from '../../utils/index.js';
4
42
 
5
43
  let {
6
44
  ref = $bindable(null),
7
45
  value = $bindable(''),
46
+ icon = null,
47
+ bare = false,
8
48
  class: className,
9
49
  ...restProps
10
50
  }: HTMLInputAttributes & {
11
51
  ref?: HTMLInputElement | null;
52
+ /** Lucide glyph rendered inside the field, left of the text. */
53
+ icon?: Component<IconProps> | null;
54
+ /** Drop the field chrome — for an input inside a container that is already the field. */
55
+ bare?: boolean;
12
56
  } = $props();
57
+
58
+ // No era-text-trim on the <input> itself, in either branch: an <input> clips to
59
+ // a UA-owned inner editor box that IS the trimmed line box, so the trim slices
60
+ // the ascenders and descenders off what you type (a "d" renders as an "o"). The
61
+ // fixed tier height centres the text on its own. See styles/index.css.
62
+ const textClass = 'text-body text-fg placeholder:text-muted';
13
63
  </script>
14
64
 
15
- <input
16
- bind:this={ref}
17
- bind:value
18
- class={cn(
19
- // No era-text-trim here: an <input> clips to a UA-owned inner editor box that
20
- // IS the trimmed line box, so the trim slices the ascenders and descenders off
21
- // what you type (a "d" renders as an "o"). The fixed tier height centres the
22
- // text on its own. See the utility's note in styles/index.css.
23
- 'flex h-(--era-h-md) w-full era-interactive rounded-(--era-rd-md) bg-(--era-surface-bg-elevated) px-(--era-field-px) text-body text-fg shadow-(--era-shadow-well) placeholder:text-muted hover:bg-(--era-highlight) focus:bg-(--era-highlight)',
24
- className
25
- )}
26
- {...restProps}
27
- />
65
+ {#if icon}
66
+ {@const Icon = icon}
67
+ <!-- The chrome moves to the wrapper so the glyph sits INSIDE the field. The
68
+ glyph is on the icon tier of the field's own height, and the gap between
69
+ it and the text is the universal inter-element gap. -->
70
+ <div class={cn(inputVariants({ bare }), 'items-center gap-(--era-gap)', className)}>
71
+ <Icon class="size-(--era-h-xs) shrink-0 text-muted" />
72
+ <input
73
+ bind:this={ref}
74
+ bind:value
75
+ class={cn('h-full w-full min-w-0 bg-transparent outline-none', textClass)}
76
+ {...restProps}
77
+ />
78
+ </div>
79
+ {:else}
80
+ <input
81
+ bind:this={ref}
82
+ bind:value
83
+ class={cn(inputVariants({ bare }), textClass, className)}
84
+ {...restProps}
85
+ />
86
+ {/if}
@@ -1,7 +1,58 @@
1
+ /**
2
+ * The field shell — the chrome an input paints, minus the text styling.
3
+ *
4
+ * Split out as a variant because an `icon` input has to move that chrome onto
5
+ * a WRAPPER (the glyph and the <input> sit inside one field), while a plain
6
+ * one keeps it on the <input> itself. Both spellings therefore have to
7
+ * describe the same box, and a variant is how they stay one description.
8
+ */
9
+ export declare const inputVariants: import("tailwind-variants").TVReturnType<{
10
+ /**
11
+ * `bare` drops the field chrome entirely — no fill, no well, no side
12
+ * padding. For an input nested in a container that is ALREADY the field:
13
+ * a Bar acting as a toolbar, a header row. Without it the input draws a
14
+ * second box inside the first, and at the same tier it fills its parent
15
+ * edge to edge so the two have no gap between them.
16
+ */
17
+ bare: {
18
+ false: "h-(--era-h-md) rounded-(--era-rd-md) bg-(--era-surface-bg-elevated) px-(--era-field-px) shadow-(--era-shadow-well) hover:bg-(--era-highlight) focus-within:bg-(--era-highlight) focus:bg-(--era-highlight)";
19
+ true: "h-full bg-transparent px-0 shadow-none";
20
+ };
21
+ }, undefined, "flex w-full era-interactive text-body text-fg", {
22
+ /**
23
+ * `bare` drops the field chrome entirely — no fill, no well, no side
24
+ * padding. For an input nested in a container that is ALREADY the field:
25
+ * a Bar acting as a toolbar, a header row. Without it the input draws a
26
+ * second box inside the first, and at the same tier it fills its parent
27
+ * edge to edge so the two have no gap between them.
28
+ */
29
+ bare: {
30
+ false: "h-(--era-h-md) rounded-(--era-rd-md) bg-(--era-surface-bg-elevated) px-(--era-field-px) shadow-(--era-shadow-well) hover:bg-(--era-highlight) focus-within:bg-(--era-highlight) focus:bg-(--era-highlight)";
31
+ true: "h-full bg-transparent px-0 shadow-none";
32
+ };
33
+ }, undefined, import("tailwind-variants").TVReturnTypeLike<{
34
+ /**
35
+ * `bare` drops the field chrome entirely — no fill, no well, no side
36
+ * padding. For an input nested in a container that is ALREADY the field:
37
+ * a Bar acting as a toolbar, a header row. Without it the input draws a
38
+ * second box inside the first, and at the same tier it fills its parent
39
+ * edge to edge so the two have no gap between them.
40
+ */
41
+ bare: {
42
+ false: "h-(--era-h-md) rounded-(--era-rd-md) bg-(--era-surface-bg-elevated) px-(--era-field-px) shadow-(--era-shadow-well) hover:bg-(--era-highlight) focus-within:bg-(--era-highlight) focus:bg-(--era-highlight)";
43
+ true: "h-full bg-transparent px-0 shadow-none";
44
+ };
45
+ }, undefined>>;
46
+ import type { Component } from 'svelte';
47
+ import type { IconProps } from '@lucide/svelte';
1
48
  import type { HTMLInputAttributes } from 'svelte/elements';
2
49
  type $$ComponentProps = HTMLInputAttributes & {
3
50
  ref?: HTMLInputElement | null;
51
+ /** Lucide glyph rendered inside the field, left of the text. */
52
+ icon?: Component<IconProps> | null;
53
+ /** Drop the field chrome — for an input inside a container that is already the field. */
54
+ bare?: boolean;
4
55
  };
5
- declare const Input: import("svelte").Component<$$ComponentProps, {}, "value" | "ref">;
56
+ declare const Input: Component<$$ComponentProps, {}, "value" | "ref">;
6
57
  type Input = ReturnType<typeof Input>;
7
58
  export default Input;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "3.13.1",
3
+ "version": "3.14.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",