@signal9/era-ui 4.3.1 → 4.4.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.
@@ -199,6 +199,9 @@
199
199
  --era-xs-inset-lg: calc(
200
200
  (var(--era-h-lg) - var(--era-h-xs)) / 2
201
201
  ); /* 9/12/15 — center an xs control inside an lg bar (= rd-lg − rd-xs) */
202
+ --era-sm-inset-md: calc(
203
+ (var(--era-h-md) - var(--era-h-sm)) / 2
204
+ ); /* 1/2/3 — center an sm control inside an md field (= rd-md − rd-sm), e.g. the date picker's trailing trigger */
202
205
  --era-sm-inset-lg: calc(
203
206
  (var(--era-h-lg) - var(--era-h-sm)) / 2
204
207
  ); /* 5/7/9 = sp − 3 — inset an sm panel (drag bar) concentrically in an lg pane (= rd-lg − rd-sm) */
@@ -71,6 +71,15 @@ export const buttonVariants = tv({
71
71
  // the box: sp+4 in xxs (pad 3), sp+2 in xs/icon (pad 2). Scoped to icon:true
72
72
  // so text+icon buttons keep their h-xs glyph. The descendant selector
73
73
  // outranks the consumer's own size-* on the svg, so no call site changes.
74
+ //
75
+ // default/sm were DESCRIBED by the comment above but had no rule, so an
76
+ // icon button at either size fell through to Lucide's own 24px default
77
+ // unless the call site happened to pass a size class. That is how the
78
+ // notes "new note" button rendered a 24px glyph in a 24px box — flush,
79
+ // no padding at all. Stating them makes the documented intent real and
80
+ // removes the dependency on every call site remembering.
81
+ { icon: true, size: 'default', class: '[&_svg]:size-(--era-h-xs)' },
82
+ { icon: true, size: 'sm', class: '[&_svg]:size-(--era-h-xs)' },
74
83
  { icon: true, size: 'xxs', class: '[&_svg]:size-(--era-icon-xxs)' },
75
84
  { icon: true, size: 'xs', class: '[&_svg]:size-(--era-icon-xs)' },
76
85
  { icon: true, size: 'icon', class: '[&_svg]:size-(--era-icon-xs)' },
@@ -1,14 +1,18 @@
1
1
  <script lang="ts">
2
2
  import { Combobox } from 'bits-ui';
3
3
  import { cn } from '../../utils/index.js';
4
+ import { inputVariants } from '../input/variants.js';
4
5
 
5
6
  let { ref = $bindable(null), class: className, ...restProps }: Combobox.InputProps = $props();
6
7
  </script>
7
8
 
8
9
  <Combobox.Input
9
10
  bind:ref
11
+ data-input-field
10
12
  class={cn(
11
- 'flex h-(--era-h-md) w-full era-interactive rounded-(--era-rd-md) bg-(--era-field-bg) px-(--era-field-px) text-body text-fg shadow-(--era-shadow-well) placeholder:text-muted hover:bg-(--era-highlight) focus:bg-(--era-highlight) data-[state=open]:rounded-b-none data-[state=open]:shadow-(--era-shadow-pressed)',
13
+ inputVariants(),
14
+ // The open list is docked to the field, so the seam between them opens up.
15
+ 'data-[state=open]:rounded-b-none data-[state=open]:shadow-(--era-shadow-pressed)',
12
16
  className
13
17
  )}
14
18
  {...restProps}
@@ -2,7 +2,7 @@
2
2
  import { Command } from 'bits-ui';
3
3
  import type { Component } from 'svelte';
4
4
  import type { IconProps } from '@lucide/svelte';
5
- import { cn } from '../../utils/index.js';
5
+ import Input from '../input/input.svelte';
6
6
 
7
7
  let {
8
8
  ref = $bindable(null),
@@ -16,30 +16,20 @@
16
16
  } = $props();
17
17
  </script>
18
18
 
19
- {#if icon}
20
- {@const Icon = icon}
21
- <div
22
- class={cn(
23
- 'flex h-(--era-h-md) w-full era-interactive items-center gap-(--era-gap) rounded-(--era-rd-md) bg-(--era-field-bg) px-(--era-field-px) text-body era-text-trim text-fg shadow-(--era-shadow-well) focus-within:bg-(--era-highlight) hover:bg-(--era-highlight)',
24
- className
25
- )}
26
- >
27
- <Icon class="size-(--era-h-xs) shrink-0 text-muted" />
28
- <Command.Input
29
- bind:ref
30
- bind:value
31
- class="h-full w-full bg-transparent text-fg outline-none placeholder:text-muted"
32
- {...restProps}
33
- />
34
- </div>
35
- {:else}
36
- <Command.Input
37
- bind:ref
38
- bind:value
39
- class={cn(
40
- 'flex h-(--era-h-md) w-full era-interactive rounded-(--era-rd-md) bg-(--era-field-bg) px-(--era-field-px) text-body text-fg shadow-(--era-shadow-well) placeholder:text-muted hover:bg-(--era-highlight) focus:bg-(--era-highlight)',
41
- className
42
- )}
43
- {...restProps}
44
- />
45
- {/if}
19
+ <!--
20
+ The search line of a command palette is a plain era Input, so this renders the
21
+ real component instead of a copy of its classes. It used to restate the whole
22
+ field chrome AND re-implement the icon branch — a second Input in all but
23
+ name, free to drift from the first, which it had.
24
+
25
+ `child` is what makes the delegation possible: bits hands us its behaviour as
26
+ props (the combobox roles, the active-descendant wiring, the ref attachment)
27
+ and we spread them onto our own element. `value` is bound in both places on
28
+ purpose — bits does not bind it for us in the child branch, so the Input's
29
+ binding is what feeds the palette's search state.
30
+ -->
31
+ <Command.Input bind:ref bind:value {...restProps}>
32
+ {#snippet child({ props })}
33
+ <Input bind:value {icon} class={className} {...props} />
34
+ {/snippet}
35
+ </Command.Input>
@@ -1,14 +1,17 @@
1
1
  <script lang="ts">
2
2
  import { DateField } from 'bits-ui';
3
3
  import { cn } from '../../utils/index.js';
4
+ import { inputVariants } from '../input/variants.js';
4
5
 
5
6
  let { ref = $bindable(null), class: className, ...restProps }: DateField.InputProps = $props();
6
7
  </script>
7
8
 
8
9
  <DateField.Input
9
10
  bind:ref
11
+ data-input-field
10
12
  class={cn(
11
- 'flex h-(--era-h-md) w-full items-center rounded-(--era-rd-md) bg-(--era-field-bg) px-(--era-field-px) text-body text-fg shadow-(--era-shadow-well) focus-within:bg-(--era-highlight) hover:bg-(--era-highlight) disabled:opacity-50 data-[invalid]:text-destructive',
13
+ inputVariants(),
14
+ 'items-center disabled:opacity-50 data-[invalid]:text-destructive',
12
15
  className
13
16
  )}
14
17
  {...restProps}
@@ -1,14 +1,17 @@
1
1
  <script lang="ts">
2
2
  import { DatePicker } from 'bits-ui';
3
3
  import { cn } from '../../utils/index.js';
4
+ import { inputVariants } from '../input/variants.js';
4
5
 
5
6
  let { ref = $bindable(null), class: className, ...restProps }: DatePicker.InputProps = $props();
6
7
  </script>
7
8
 
8
9
  <DatePicker.Input
9
10
  bind:ref
11
+ data-input-field
10
12
  class={cn(
11
- 'flex h-(--era-h-md) w-full items-center rounded-(--era-rd-md) bg-(--era-field-bg) ps-(--era-inset-md) pe-(--era-px-md) text-body text-fg shadow-(--era-shadow-well) focus-within:bg-(--era-highlight) hover:bg-(--era-highlight) disabled:opacity-50 data-[invalid]:text-destructive',
13
+ inputVariants({ pad: 'trailing' }),
14
+ 'items-center disabled:opacity-50 data-[invalid]:text-destructive',
12
15
  className
13
16
  )}
14
17
  {...restProps}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { DateRangeField } from 'bits-ui';
3
3
  import { cn } from '../../utils/index.js';
4
+ import { inputVariants } from '../input/variants.js';
4
5
 
5
6
  let {
6
7
  ref = $bindable(null),
@@ -11,8 +12,10 @@
11
12
 
12
13
  <DateRangeField.Input
13
14
  bind:ref
15
+ data-input-field
14
16
  class={cn(
15
- 'flex h-(--era-h-md) w-full items-center rounded-(--era-rd-md) bg-(--era-field-bg) px-(--era-field-px) text-body text-fg shadow-(--era-shadow-well) focus-within:bg-(--era-highlight) hover:bg-(--era-highlight) disabled:opacity-50 data-[invalid]:text-destructive',
17
+ inputVariants(),
18
+ 'items-center disabled:opacity-50 data-[invalid]:text-destructive',
16
19
  className
17
20
  )}
18
21
  {...restProps}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { DateRangePicker } from 'bits-ui';
3
3
  import { cn } from '../../utils/index.js';
4
+ import { inputVariants } from '../input/variants.js';
4
5
 
5
6
  let {
6
7
  ref = $bindable(null),
@@ -11,8 +12,10 @@
11
12
 
12
13
  <DateRangePicker.Input
13
14
  bind:ref
15
+ data-input-field
14
16
  class={cn(
15
- 'flex h-(--era-h-md) items-center rounded-(--era-rd-md) bg-(--era-field-bg) ps-(--era-inset-md) pe-(--era-px-md) text-body text-fg shadow-(--era-shadow-well) focus-within:bg-(--era-highlight) hover:bg-(--era-highlight) disabled:opacity-50 data-[invalid]:text-destructive',
17
+ inputVariants({ pad: 'trailing' }),
18
+ 'items-center disabled:opacity-50 data-[invalid]:text-destructive',
16
19
  className
17
20
  )}
18
21
  {...restProps}
@@ -1,2 +1,3 @@
1
1
  export { default as Input } from './input.svelte';
2
2
  export { default as Textarea } from './textarea.svelte';
3
+ export { inputVariants } from './variants.js';
@@ -1,2 +1,5 @@
1
1
  export { default as Input } from './input.svelte';
2
2
  export { default as Textarea } from './textarea.svelte';
3
+ // The field chrome itself, so a component that builds its own field composes
4
+ // this rather than restating it. See variants.ts.
5
+ export { inputVariants } from './variants.js';
@@ -1,42 +1,7 @@
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-field-bg) 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
-
38
1
  <script lang="ts">
39
2
  import type { Component } from 'svelte';
3
+ import { cn } from '../../utils/index.js';
4
+ import { inputVariants } from './variants.js';
40
5
  import type { IconProps } from '@lucide/svelte';
41
6
  import type { HTMLInputAttributes } from 'svelte/elements';
42
7
 
@@ -45,6 +10,7 @@
45
10
  value = $bindable(''),
46
11
  icon = null,
47
12
  bare = false,
13
+ reveal = false,
48
14
  class: className,
49
15
  ...restProps
50
16
  }: HTMLInputAttributes & {
@@ -53,6 +19,8 @@
53
19
  icon?: Component<IconProps> | null;
54
20
  /** Drop the field chrome — for an input inside a container that is already the field. */
55
21
  bare?: boolean;
22
+ /** Hold the field chrome back until hover — for a title or label that IS the content. */
23
+ reveal?: boolean;
56
24
  } = $props();
57
25
 
58
26
  // No era-text-trim on the <input> itself, in either branch: an <input> clips to
@@ -60,6 +28,12 @@
60
28
  // the ascenders and descenders off what you type (a "d" renders as an "o"). The
61
29
  // fixed tier height centres the text on its own. See styles/index.css.
62
30
  const textClass = 'text-body text-fg placeholder:text-muted';
31
+
32
+ // The marker says WHICH field this is, not just that it is one. `bare` and
33
+ // `reveal` both render chrome-less at rest, so a test that only sees "no
34
+ // chrome" cannot tell a sanctioned exception from a regression — which is the
35
+ // hole the Notes fields sat in. Naming the kind closes it.
36
+ const kind = $derived(reveal ? 'reveal' : bare ? 'bare' : '');
63
37
  </script>
64
38
 
65
39
  {#if icon}
@@ -67,7 +41,15 @@
67
41
  <!-- The chrome moves to the wrapper so the glyph sits INSIDE the field. The
68
42
  glyph is on the icon tier of the field's own height, and the gap between
69
43
  it and the text is the universal inter-element gap. -->
70
- <div class={cn(inputVariants({ bare }), 'items-center gap-(--era-gap)', className)}>
44
+ <!-- data-input-field marks whichever element actually PAINTS the field. With
45
+ an icon that is this wrapper; without one it is the <input> itself. Tests
46
+ and consumers can then target the field directly instead of guessing from
47
+ the DOM shape — guessing breaks on a `bare` input, whose parent is an
48
+ arbitrary container that may carry its own fill. -->
49
+ <div
50
+ data-input-field={kind}
51
+ class={cn(inputVariants({ bare, reveal }), 'items-center gap-(--era-gap)', className)}
52
+ >
71
53
  <Icon class="size-(--era-h-xs) shrink-0 text-muted" />
72
54
  <input
73
55
  bind:this={ref}
@@ -80,7 +62,8 @@
80
62
  <input
81
63
  bind:this={ref}
82
64
  bind:value
83
- class={cn(inputVariants({ bare }), textClass, className)}
65
+ data-input-field={kind}
66
+ class={cn(inputVariants({ bare, reveal }), textClass, className)}
84
67
  {...restProps}
85
68
  />
86
69
  {/if}
@@ -1,48 +1,3 @@
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-field-bg) 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-field-bg) 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-field-bg) 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
1
  import type { Component } from 'svelte';
47
2
  import type { IconProps } from '@lucide/svelte';
48
3
  import type { HTMLInputAttributes } from 'svelte/elements';
@@ -52,6 +7,8 @@ type $$ComponentProps = HTMLInputAttributes & {
52
7
  icon?: Component<IconProps> | null;
53
8
  /** Drop the field chrome — for an input inside a container that is already the field. */
54
9
  bare?: boolean;
10
+ /** Hold the field chrome back until hover — for a title or label that IS the content. */
11
+ reveal?: boolean;
55
12
  };
56
13
  declare const Input: Component<$$ComponentProps, {}, "value" | "ref">;
57
14
  type Input = ReturnType<typeof Input>;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { HTMLTextareaAttributes } from 'svelte/elements';
3
3
  import { cn } from '../../utils/index.js';
4
+ import { inputVariants } from './variants.js';
4
5
 
5
6
  let {
6
7
  ref = $bindable(null),
@@ -15,8 +16,12 @@
15
16
  <textarea
16
17
  bind:this={ref}
17
18
  bind:value
19
+ data-input-field
18
20
  class={cn(
19
- 'flex min-h-(--era-h-md) w-full era-interactive resize-y rounded-(--era-rd-md) bg-(--era-field-bg) px-(--era-field-px) py-(--era-inset-md) text-body text-fg shadow-(--era-shadow-well) placeholder:text-muted hover:bg-(--era-highlight) focus:bg-(--era-highlight)',
21
+ inputVariants({ height: 'auto' }),
22
+ // It grows, so the vertical inset has to be stated: the tier height is what
23
+ // centres one line of text in every other field, and there isn't one here.
24
+ 'resize-y py-(--era-inset-md)',
20
25
  className
21
26
  )}
22
27
  {...restProps}
@@ -0,0 +1,153 @@
1
+ /**
2
+ * THE field. Every text field in the library paints this and nothing else.
3
+ *
4
+ * This module exists because it did not: the chrome below was hand-copied into
5
+ * Input, Textarea, CommandInput, ComboboxInput and all six date/time field
6
+ * inputs, and the copies had already drifted apart — some used `focus:`, others
7
+ * `focus-within:`, some omitted `era-interactive`, the date picker used a
8
+ * different leading padding, and the Notes fields had dropped the chrome
9
+ * entirely. Nine restatements of one idea is nine chances to be inconsistent,
10
+ * so there is now one statement and the rest import it.
11
+ *
12
+ * A component that needs something different asks for it as a VARIANT here,
13
+ * where every other field can see it — never by respelling the base.
14
+ *
15
+ * `tests/visual/input-consistency.spec.ts` measures the rendered result across
16
+ * routes and fails if any two fields differ.
17
+ */
18
+ export declare const inputVariants: import("tailwind-variants").TVReturnType<{
19
+ /**
20
+ * `bare` drops the field chrome entirely — no fill, no well, no side
21
+ * padding. For an input nested in a container that is ALREADY the field:
22
+ * a Bar acting as a toolbar, a header row. Without it the input draws a
23
+ * second box inside the first, and at the same tier it fills its parent
24
+ * edge to edge so the two have no gap between them.
25
+ */
26
+ bare: {
27
+ false: "rounded-(--era-rd-md) bg-(--era-field-bg) shadow-(--era-shadow-well) hover:bg-(--era-highlight) focus-within:bg-(--era-highlight) focus:bg-(--era-highlight)";
28
+ true: "bg-transparent shadow-none";
29
+ };
30
+ /**
31
+ * `reveal` holds the field back until the pointer arrives: no fill and no
32
+ * well at rest, the ordinary resting field on hover, the ordinary focused
33
+ * field on focus. For a field whose value is really the page's own content
34
+ * rather than a form entry — a document title, a renameable label — where
35
+ * a box drawn around it at all times competes with the thing it names.
36
+ *
37
+ * It is deliberately a variant here and not a one-off class on the caller:
38
+ * a revealed field has to reveal THE field, and the only way to guarantee
39
+ * that is for it to be spelled once, next to it.
40
+ */
41
+ reveal: {
42
+ true: "bg-transparent shadow-none hover:bg-(--era-field-bg) hover:shadow-(--era-shadow-well) focus-within:shadow-(--era-shadow-well) focus:shadow-(--era-shadow-well)";
43
+ false: "";
44
+ };
45
+ /** Vertical size. `auto` is for a field that grows with its content (Textarea). */
46
+ height: {
47
+ md: "h-(--era-h-md)";
48
+ full: "h-full";
49
+ auto: "min-h-(--era-h-md)";
50
+ };
51
+ /**
52
+ * Side padding. `trailing` is for a field that holds a control at its end
53
+ * (the date picker's calendar trigger): the leading edge keeps the shared
54
+ * text inset so the text still lines up with every other field, and the
55
+ * trailing edge becomes the concentric inset for that sm-tier control, so
56
+ * its gap to the field edge equals its gap above and below.
57
+ */
58
+ pad: {
59
+ even: "px-(--era-field-px)";
60
+ trailing: "ps-(--era-field-px) pe-(--era-sm-inset-md)";
61
+ none: "px-0";
62
+ };
63
+ }, undefined, "flex w-full era-interactive text-body text-fg placeholder:text-muted", {
64
+ /**
65
+ * `bare` drops the field chrome entirely — no fill, no well, no side
66
+ * padding. For an input nested in a container that is ALREADY the field:
67
+ * a Bar acting as a toolbar, a header row. Without it the input draws a
68
+ * second box inside the first, and at the same tier it fills its parent
69
+ * edge to edge so the two have no gap between them.
70
+ */
71
+ bare: {
72
+ false: "rounded-(--era-rd-md) bg-(--era-field-bg) shadow-(--era-shadow-well) hover:bg-(--era-highlight) focus-within:bg-(--era-highlight) focus:bg-(--era-highlight)";
73
+ true: "bg-transparent shadow-none";
74
+ };
75
+ /**
76
+ * `reveal` holds the field back until the pointer arrives: no fill and no
77
+ * well at rest, the ordinary resting field on hover, the ordinary focused
78
+ * field on focus. For a field whose value is really the page's own content
79
+ * rather than a form entry — a document title, a renameable label — where
80
+ * a box drawn around it at all times competes with the thing it names.
81
+ *
82
+ * It is deliberately a variant here and not a one-off class on the caller:
83
+ * a revealed field has to reveal THE field, and the only way to guarantee
84
+ * that is for it to be spelled once, next to it.
85
+ */
86
+ reveal: {
87
+ true: "bg-transparent shadow-none hover:bg-(--era-field-bg) hover:shadow-(--era-shadow-well) focus-within:shadow-(--era-shadow-well) focus:shadow-(--era-shadow-well)";
88
+ false: "";
89
+ };
90
+ /** Vertical size. `auto` is for a field that grows with its content (Textarea). */
91
+ height: {
92
+ md: "h-(--era-h-md)";
93
+ full: "h-full";
94
+ auto: "min-h-(--era-h-md)";
95
+ };
96
+ /**
97
+ * Side padding. `trailing` is for a field that holds a control at its end
98
+ * (the date picker's calendar trigger): the leading edge keeps the shared
99
+ * text inset so the text still lines up with every other field, and the
100
+ * trailing edge becomes the concentric inset for that sm-tier control, so
101
+ * its gap to the field edge equals its gap above and below.
102
+ */
103
+ pad: {
104
+ even: "px-(--era-field-px)";
105
+ trailing: "ps-(--era-field-px) pe-(--era-sm-inset-md)";
106
+ none: "px-0";
107
+ };
108
+ }, undefined, import("tailwind-variants").TVReturnTypeLike<{
109
+ /**
110
+ * `bare` drops the field chrome entirely — no fill, no well, no side
111
+ * padding. For an input nested in a container that is ALREADY the field:
112
+ * a Bar acting as a toolbar, a header row. Without it the input draws a
113
+ * second box inside the first, and at the same tier it fills its parent
114
+ * edge to edge so the two have no gap between them.
115
+ */
116
+ bare: {
117
+ false: "rounded-(--era-rd-md) bg-(--era-field-bg) shadow-(--era-shadow-well) hover:bg-(--era-highlight) focus-within:bg-(--era-highlight) focus:bg-(--era-highlight)";
118
+ true: "bg-transparent shadow-none";
119
+ };
120
+ /**
121
+ * `reveal` holds the field back until the pointer arrives: no fill and no
122
+ * well at rest, the ordinary resting field on hover, the ordinary focused
123
+ * field on focus. For a field whose value is really the page's own content
124
+ * rather than a form entry — a document title, a renameable label — where
125
+ * a box drawn around it at all times competes with the thing it names.
126
+ *
127
+ * It is deliberately a variant here and not a one-off class on the caller:
128
+ * a revealed field has to reveal THE field, and the only way to guarantee
129
+ * that is for it to be spelled once, next to it.
130
+ */
131
+ reveal: {
132
+ true: "bg-transparent shadow-none hover:bg-(--era-field-bg) hover:shadow-(--era-shadow-well) focus-within:shadow-(--era-shadow-well) focus:shadow-(--era-shadow-well)";
133
+ false: "";
134
+ };
135
+ /** Vertical size. `auto` is for a field that grows with its content (Textarea). */
136
+ height: {
137
+ md: "h-(--era-h-md)";
138
+ full: "h-full";
139
+ auto: "min-h-(--era-h-md)";
140
+ };
141
+ /**
142
+ * Side padding. `trailing` is for a field that holds a control at its end
143
+ * (the date picker's calendar trigger): the leading edge keeps the shared
144
+ * text inset so the text still lines up with every other field, and the
145
+ * trailing edge becomes the concentric inset for that sm-tier control, so
146
+ * its gap to the field edge equals its gap above and below.
147
+ */
148
+ pad: {
149
+ even: "px-(--era-field-px)";
150
+ trailing: "ps-(--era-field-px) pe-(--era-sm-inset-md)";
151
+ none: "px-0";
152
+ };
153
+ }, undefined>>;
@@ -0,0 +1,78 @@
1
+ import { tv } from '../../utils/index.js';
2
+ /**
3
+ * THE field. Every text field in the library paints this and nothing else.
4
+ *
5
+ * This module exists because it did not: the chrome below was hand-copied into
6
+ * Input, Textarea, CommandInput, ComboboxInput and all six date/time field
7
+ * inputs, and the copies had already drifted apart — some used `focus:`, others
8
+ * `focus-within:`, some omitted `era-interactive`, the date picker used a
9
+ * different leading padding, and the Notes fields had dropped the chrome
10
+ * entirely. Nine restatements of one idea is nine chances to be inconsistent,
11
+ * so there is now one statement and the rest import it.
12
+ *
13
+ * A component that needs something different asks for it as a VARIANT here,
14
+ * where every other field can see it — never by respelling the base.
15
+ *
16
+ * `tests/visual/input-consistency.spec.ts` measures the rendered result across
17
+ * routes and fails if any two fields differ.
18
+ */
19
+ export const inputVariants = tv({
20
+ base: 'flex w-full era-interactive text-body text-fg placeholder:text-muted',
21
+ variants: {
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: 'rounded-(--era-rd-md) bg-(--era-field-bg) shadow-(--era-shadow-well) hover:bg-(--era-highlight) focus-within:bg-(--era-highlight) focus:bg-(--era-highlight)',
31
+ true: 'bg-transparent shadow-none'
32
+ },
33
+ /**
34
+ * `reveal` holds the field back until the pointer arrives: no fill and no
35
+ * well at rest, the ordinary resting field on hover, the ordinary focused
36
+ * field on focus. For a field whose value is really the page's own content
37
+ * rather than a form entry — a document title, a renameable label — where
38
+ * a box drawn around it at all times competes with the thing it names.
39
+ *
40
+ * It is deliberately a variant here and not a one-off class on the caller:
41
+ * a revealed field has to reveal THE field, and the only way to guarantee
42
+ * that is for it to be spelled once, next to it.
43
+ */
44
+ reveal: {
45
+ true: 'bg-transparent shadow-none hover:bg-(--era-field-bg) hover:shadow-(--era-shadow-well) focus-within:shadow-(--era-shadow-well) focus:shadow-(--era-shadow-well)',
46
+ false: ''
47
+ },
48
+ /** Vertical size. `auto` is for a field that grows with its content (Textarea). */
49
+ height: {
50
+ md: 'h-(--era-h-md)',
51
+ // h-full, NOT the md tier: a bare input's container IS the field, so it
52
+ // takes that container's height. Keeping h-md here made a bare input
53
+ // inside an h-md Bar overflow it by the bar's border — a −0.5px gap,
54
+ // which is the even-gap law failing by a hair rather than obviously.
55
+ full: 'h-full',
56
+ auto: 'min-h-(--era-h-md)'
57
+ },
58
+ /**
59
+ * Side padding. `trailing` is for a field that holds a control at its end
60
+ * (the date picker's calendar trigger): the leading edge keeps the shared
61
+ * text inset so the text still lines up with every other field, and the
62
+ * trailing edge becomes the concentric inset for that sm-tier control, so
63
+ * its gap to the field edge equals its gap above and below.
64
+ */
65
+ pad: {
66
+ even: 'px-(--era-field-px)',
67
+ trailing: 'ps-(--era-field-px) pe-(--era-sm-inset-md)',
68
+ none: 'px-0'
69
+ }
70
+ },
71
+ compoundVariants: [
72
+ // Chrome and padding travel together: a field with no chrome has no box to
73
+ // pad. Spelling this as a compound rather than leaving it to the caller is
74
+ // what stops `bare` from becoming a second, differently-padded field.
75
+ { bare: true, class: 'h-full px-0' }
76
+ ],
77
+ defaultVariants: { bare: false, reveal: false, height: 'md', pad: 'even' }
78
+ });
@@ -1,14 +1,17 @@
1
1
  <script lang="ts">
2
2
  import { TimeField } from 'bits-ui';
3
3
  import { cn } from '../../utils/index.js';
4
+ import { inputVariants } from '../input/variants.js';
4
5
 
5
6
  let { ref = $bindable(null), class: className, ...restProps }: TimeField.InputProps = $props();
6
7
  </script>
7
8
 
8
9
  <TimeField.Input
9
10
  bind:ref
11
+ data-input-field
10
12
  class={cn(
11
- 'flex h-(--era-h-md) w-full items-center rounded-(--era-rd-md) bg-(--era-field-bg) px-(--era-field-px) text-body text-fg shadow-(--era-shadow-well) focus-within:bg-(--era-highlight) hover:bg-(--era-highlight) disabled:opacity-50 data-[invalid]:text-destructive',
13
+ inputVariants(),
14
+ 'items-center disabled:opacity-50 data-[invalid]:text-destructive',
12
15
  className
13
16
  )}
14
17
  {...restProps}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { TimeRangeField } from 'bits-ui';
3
3
  import { cn } from '../../utils/index.js';
4
+ import { inputVariants } from '../input/variants.js';
4
5
 
5
6
  let {
6
7
  ref = $bindable(null),
@@ -11,8 +12,10 @@
11
12
 
12
13
  <TimeRangeField.Input
13
14
  bind:ref
15
+ data-input-field
14
16
  class={cn(
15
- 'flex h-(--era-h-md) w-full items-center rounded-(--era-rd-md) bg-(--era-field-bg) px-(--era-field-px) text-body text-fg shadow-(--era-shadow-well) focus-within:bg-(--era-highlight) hover:bg-(--era-highlight) disabled:opacity-50 data-[invalid]:text-destructive',
17
+ inputVariants(),
18
+ 'items-center disabled:opacity-50 data-[invalid]:text-destructive',
16
19
  className
17
20
  )}
18
21
  {...restProps}