@lyeve-labs/ui-kit 0.39.0 → 0.40.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.
@@ -25,7 +25,7 @@
25
25
  * The id of the caption, for a group's `aria-labelledby`. Set only when
26
26
  * the field is a `group` with a label.
27
27
  */
28
- labelledBy: string | undefined;
28
+ labeledBy: string | undefined;
29
29
  }
30
30
  </script>
31
31
 
@@ -48,8 +48,12 @@
48
48
  /**
49
49
  * The control is a group, a `SegmentedControl`, `FilterChips` or a row of
50
50
  * your own. A label's `for` reaches one form control and never a group, so
51
- * the caption is drawn as text with an id instead, and the group takes its
52
- * name from it through `labelledBy`.
51
+ * the caption is drawn as text with an id instead, handed over as
52
+ * `labeledBy`. A `SegmentedControl` takes its name from it, and draws no
53
+ * caption of its own once given `labelHidden` as well. `FilterChips` takes
54
+ * no `labeledBy`. It names its group from its own `label`, so give that
55
+ * label the caption's words. A row of your own points `aria-labelledby` at
56
+ * the caption.
53
57
  */
54
58
  group?: boolean;
55
59
  hint?: string;
@@ -91,17 +95,19 @@
91
95
  id: fieldId,
92
96
  describedBy: describedBy(fieldId, error, hint),
93
97
  invalid: !!error,
94
- labelledBy: group && label ? `${fieldId}-label` : undefined,
98
+ labeledBy: group && label ? `${fieldId}-label` : undefined,
95
99
  });
96
100
  </script>
97
101
 
98
102
  <div data-field class="{FIELD_WRAP} {klass}">
99
103
  {#if label}
100
104
  <!--
101
- A hidden label stays a real label with a real `for`. Swapping it for an
102
- aria-label on the control would name the control and drop the name out of
103
- the reading order, so a reader moving through a row of them would meet
104
- the controls with nothing ahead of them saying what each one is.
105
+ A hidden label stays in the page: a real label with a real `for`, or for
106
+ a group a caption with an id that a SegmentedControl or a row of your own
107
+ points at. Swapping it for an aria-label on the control would name the
108
+ control and drop the name out of the reading order, so a reader moving
109
+ through a row of them would meet the controls with nothing ahead of them
110
+ saying what each one is.
105
111
  -->
106
112
  {#if group}
107
113
  <span id="{fieldId}-label" class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
@@ -24,7 +24,7 @@ export interface FieldWiring {
24
24
  * The id of the caption, for a group's `aria-labelledby`. Set only when
25
25
  * the field is a `group` with a label.
26
26
  */
27
- labelledBy: string | undefined;
27
+ labeledBy: string | undefined;
28
28
  }
29
29
  import type { Snippet } from 'svelte';
30
30
  interface Props {
@@ -36,8 +36,12 @@ interface Props {
36
36
  /**
37
37
  * The control is a group, a `SegmentedControl`, `FilterChips` or a row of
38
38
  * your own. A label's `for` reaches one form control and never a group, so
39
- * the caption is drawn as text with an id instead, and the group takes its
40
- * name from it through `labelledBy`.
39
+ * the caption is drawn as text with an id instead, handed over as
40
+ * `labeledBy`. A `SegmentedControl` takes its name from it, and draws no
41
+ * caption of its own once given `labelHidden` as well. `FilterChips` takes
42
+ * no `labeledBy`. It names its group from its own `label`, so give that
43
+ * label the caption's words. A row of your own points `aria-labelledby` at
44
+ * the caption.
41
45
  */
42
46
  group?: boolean;
43
47
  hint?: string;
@@ -14,12 +14,28 @@
14
14
  *
15
15
  * The heading is a real h2 or h3. A page's outline is what a screen reader
16
16
  * navigates by, and a styled div appears in no heading list.
17
+ *
18
+ * `tone="danger"` is the surface for destructive actions such as deleting an
19
+ * account. Alert and Banner are live regions, so a form inside one is read
20
+ * out on every change, and apps drew this box by hand instead. The tone
21
+ * colors the heading and its icon, and the box when there is one. With
22
+ * `variant="plain"` there is no box, so only the heading carries the tone:
23
+ * the variant decides whether a box is drawn and the tone decides its color,
24
+ * and neither prop silently undoes the other. That plain form is the danger
25
+ * group inside a card that already draws the box. The description stays
26
+ * muted, and the contrast suite measures every text token on the surface.
17
27
  */
18
28
  import type { Component, Snippet } from 'svelte';
19
- import { CARD_PAD, CARD_SURFACE, sectionHeading } from '../internal/layout.js';
29
+ import {
30
+ CARD_PAD,
31
+ CARD_SURFACE,
32
+ PANEL_DANGER_SURFACE,
33
+ sectionHeading,
34
+ } from '../internal/layout.js';
20
35
 
21
36
  type Pad = 'none' | 'sm' | 'md';
22
37
  type Variant = 'bordered' | 'plain';
38
+ type Tone = 'neutral' | 'danger';
23
39
 
24
40
  interface Props {
25
41
  heading?: string;
@@ -31,6 +47,8 @@
31
47
  pad?: Pad;
32
48
  /** A quiet variant with no border, for a grouping that needs separation but not a box. */
33
49
  variant?: Variant;
50
+ /** `danger` for a group of destructive actions. It composes with `plain`, see above. */
51
+ tone?: Tone;
34
52
  class?: string;
35
53
  actions?: Snippet;
36
54
  children: Snippet;
@@ -43,6 +61,7 @@
43
61
  icon = undefined,
44
62
  pad = 'md',
45
63
  variant = 'bordered',
64
+ tone = 'neutral',
46
65
  class: klass = '',
47
66
  actions,
48
67
  children,
@@ -55,9 +74,9 @@
55
74
  * and which of them wins is decided by the order Tailwind emits them and not
56
75
  * by the order they were written here.
57
76
  */
58
- const SURFACE: Record<Variant, string> = {
59
- bordered: CARD_SURFACE,
60
- plain: '',
77
+ const SURFACE: Record<Tone, Record<Variant, string>> = {
78
+ neutral: { bordered: CARD_SURFACE, plain: '' },
79
+ danger: { bordered: PANEL_DANGER_SURFACE, plain: '' },
61
80
  };
62
81
 
63
82
  /**
@@ -65,14 +84,25 @@
65
84
  * padding change. A plain panel usually takes `pad="none"`: with no box to
66
85
  * inset from, an inset only pushes its heading out of line with the page.
67
86
  */
68
- const surface = $derived(`${SURFACE[variant]} ${CARD_PAD[pad]}`.trim());
87
+ const surface = $derived(`${SURFACE[tone][variant]} ${CARD_PAD[pad]}`.trim());
88
+
89
+ /**
90
+ * The level's treatment with its one text color swapped, not a second color
91
+ * added after it: two utilities for one property resolve by emit order.
92
+ */
93
+ const headingClass = (level: 2 | 3) =>
94
+ tone === 'danger'
95
+ ? sectionHeading(level).replace(/\btext-fg\b/, 'text-danger')
96
+ : sectionHeading(level);
69
97
  </script>
70
98
 
71
99
  <div data-print="keep" class="flex flex-col gap-stack {surface} {klass}">
72
100
  {#snippet headingText()}
73
101
  {#if icon}
74
102
  {@const Icon = icon}
75
- <span class="shrink-0 text-muted" aria-hidden="true"><Icon size={16} /></span>
103
+ <span class="shrink-0 {tone === 'danger' ? 'text-danger' : 'text-muted'}" aria-hidden="true"
104
+ ><Icon size={16} /></span
105
+ >
76
106
  {/if}
77
107
  {heading}
78
108
  {/snippet}
@@ -84,9 +114,9 @@
84
114
  document structure decision, and both headings this can produce
85
115
  should be readable in the file. -->
86
116
  {#if heading && headingLevel === 2}
87
- <h2 class="{sectionHeading(2)} flex items-center gap-2">{@render headingText()}</h2>
117
+ <h2 class="{headingClass(2)} flex items-center gap-2">{@render headingText()}</h2>
88
118
  {:else if heading}
89
- <h3 class="{sectionHeading(3)} flex items-center gap-2">{@render headingText()}</h3>
119
+ <h3 class="{headingClass(3)} flex items-center gap-2">{@render headingText()}</h3>
90
120
  {/if}
91
121
  {#if description}<p class="text-sm text-muted">{description}</p>{/if}
92
122
  </div>
@@ -13,10 +13,21 @@
13
13
  *
14
14
  * The heading is a real h2 or h3. A page's outline is what a screen reader
15
15
  * navigates by, and a styled div appears in no heading list.
16
+ *
17
+ * `tone="danger"` is the surface for destructive actions such as deleting an
18
+ * account. Alert and Banner are live regions, so a form inside one is read
19
+ * out on every change, and apps drew this box by hand instead. The tone
20
+ * colors the heading and its icon, and the box when there is one. With
21
+ * `variant="plain"` there is no box, so only the heading carries the tone:
22
+ * the variant decides whether a box is drawn and the tone decides its color,
23
+ * and neither prop silently undoes the other. That plain form is the danger
24
+ * group inside a card that already draws the box. The description stays
25
+ * muted, and the contrast suite measures every text token on the surface.
16
26
  */
17
27
  import type { Component, Snippet } from 'svelte';
18
28
  type Pad = 'none' | 'sm' | 'md';
19
29
  type Variant = 'bordered' | 'plain';
30
+ type Tone = 'neutral' | 'danger';
20
31
  interface Props {
21
32
  heading?: string;
22
33
  /** 2 directly under a page title, 3 inside a card or another panel. */
@@ -30,6 +41,8 @@ interface Props {
30
41
  pad?: Pad;
31
42
  /** A quiet variant with no border, for a grouping that needs separation but not a box. */
32
43
  variant?: Variant;
44
+ /** `danger` for a group of destructive actions. It composes with `plain`, see above. */
45
+ tone?: Tone;
33
46
  class?: string;
34
47
  actions?: Snippet;
35
48
  children: Snippet;
@@ -30,6 +30,7 @@
30
30
  joinIds,
31
31
  } from '../internal/field.js';
32
32
  import { safeHref } from '../internal/href.js';
33
+ import type { IconPx } from '../internal/icon.js';
33
34
  import { HIT_AREA } from '../internal/touch.js';
34
35
 
35
36
  type Size = 'sm' | 'md';
@@ -44,7 +45,11 @@
44
45
  * is for from the segments alone.
45
46
  */
46
47
  label: string;
47
- /** Drops the caption above the row. The group keeps its accessible name. */
48
+ /**
49
+ * Drops the caption above the row. The group keeps its accessible name.
50
+ * Pass it with `labeledBy`, or the row draws its own caption as well as
51
+ * the one it is named by.
52
+ */
48
53
  labelHidden?: boolean;
49
54
  /**
50
55
  * The group's id. The hint and the error take theirs from it. Left unset,
@@ -66,9 +71,10 @@
66
71
  describedBy?: string;
67
72
  /**
68
73
  * The id of a caption drawn outside the row, a `Field` one for instance.
69
- * The group takes its name from it in place of `label`.
74
+ * The group takes its name from it in place of `label`. The row still draws
75
+ * `label` above itself until `labelHidden` is set too.
70
76
  */
71
- labelledBy?: string;
77
+ labeledBy?: string;
72
78
  /** Submits the value with the surrounding form, through a hidden input. */
73
79
  name?: string;
74
80
  size?: Size;
@@ -87,7 +93,7 @@
87
93
  hint = undefined,
88
94
  error = undefined,
89
95
  describedBy: callerIds = undefined,
90
- labelledBy = undefined,
96
+ labeledBy = undefined,
91
97
  name = undefined,
92
98
  size = 'md',
93
99
  disabled = false,
@@ -110,7 +116,7 @@
110
116
  * short label such as "6h" takes a 44px target across as well, and the hit
111
117
  * boxes of two neighbors do not overlap.
112
118
  */
113
- const SIZES: Record<Size, { row: string; segment: string; icon: number }> = {
119
+ const SIZES: Record<Size, { row: string; segment: string; icon: IconPx }> = {
114
120
  sm: { row: 'h-8 coarse:min-h-control', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
115
121
  md: { row: 'h-control', segment: 'gap-2 px-3 text-sm', icon: 16 },
116
122
  };
@@ -252,8 +258,8 @@
252
258
  bind:this={rootEl}
253
259
  {id}
254
260
  role={linked ? 'group' : 'radiogroup'}
255
- aria-label={labelledBy ? undefined : label}
256
- aria-labelledby={labelledBy}
261
+ aria-label={labeledBy ? undefined : label}
262
+ aria-labelledby={labeledBy}
257
263
  aria-invalid={error && !linked ? 'true' : undefined}
258
264
  aria-describedby={describedByIds}
259
265
  onfocusout={linked ? onGroupFocusOut : undefined}
@@ -30,7 +30,11 @@ declare function $$render<T extends string>(): {
30
30
  * is for from the segments alone.
31
31
  */
32
32
  label: string;
33
- /** Drops the caption above the row. The group keeps its accessible name. */
33
+ /**
34
+ * Drops the caption above the row. The group keeps its accessible name.
35
+ * Pass it with `labeledBy`, or the row draws its own caption as well as
36
+ * the one it is named by.
37
+ */
34
38
  labelHidden?: boolean;
35
39
  /**
36
40
  * The group's id. The hint and the error take theirs from it. Left unset,
@@ -52,9 +56,10 @@ declare function $$render<T extends string>(): {
52
56
  describedBy?: string;
53
57
  /**
54
58
  * The id of a caption drawn outside the row, a `Field` one for instance.
55
- * The group takes its name from it in place of `label`.
59
+ * The group takes its name from it in place of `label`. The row still draws
60
+ * `label` above itself until `labelHidden` is set too.
56
61
  */
57
- labelledBy?: string;
62
+ labeledBy?: string;
58
63
  /** Submits the value with the surrounding form, through a hidden input. */
59
64
  name?: string;
60
65
  size?: "sm" | "md";
package/dist/index.d.ts CHANGED
@@ -103,4 +103,4 @@ export { cn, type ClassValue } from './utils/cn.js';
103
103
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
104
104
  export * as motion from './motion.js';
105
105
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
106
- export declare const VERSION = "0.39.0";
106
+ export declare const VERSION = "0.40.0";
package/dist/index.js CHANGED
@@ -116,4 +116,4 @@ export * as motion from './motion.js';
116
116
  // ── Version ────────────────────────────────────────────────────────────────
117
117
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
118
118
  // this line. The build and the test suite fail when the two disagree.
119
- export const VERSION = '0.39.0';
119
+ export const VERSION = '0.40.0';
@@ -14,6 +14,7 @@
14
14
  *
15
15
  * Not exported from the package entry point: this is an implementation detail.
16
16
  */
17
+ import type { IconPx } from './icon.js';
17
18
  /** The scale of the painted box: sm 14px, md 16px, lg 20px. */
18
19
  export type ChoiceSize = 'sm' | 'md' | 'lg';
19
20
  /** Inline is a box beside a label. Card is a bordered option surface. */
@@ -100,9 +101,10 @@ export declare function choiceIcon(size: ChoiceSize): string;
100
101
  *
101
102
  * A lucide icon takes a number, not a class, so the slot above cannot size it.
102
103
  * These are the same 14, 16 and 20 the slot reserves. An icon rendered at any
103
- * other size overflows the slot or floats inside it.
104
+ * other size overflows the slot or floats inside it. Each is a rung of the
105
+ * icon ladder, which the type holds.
104
106
  */
105
- export declare const CHOICE_ICON_PX: Record<ChoiceSize, number>;
107
+ export declare const CHOICE_ICON_PX: Record<ChoiceSize, IconPx>;
106
108
  /**
107
109
  * The fieldset a group renders, so the set is announced as a group rather than
108
110
  * as loose controls.
@@ -142,7 +142,8 @@ export function choiceIcon(size) {
142
142
  *
143
143
  * A lucide icon takes a number, not a class, so the slot above cannot size it.
144
144
  * These are the same 14, 16 and 20 the slot reserves. An icon rendered at any
145
- * other size overflows the slot or floats inside it.
145
+ * other size overflows the slot or floats inside it. Each is a rung of the
146
+ * icon ladder, which the type holds.
146
147
  */
147
148
  export const CHOICE_ICON_PX = {
148
149
  sm: 14,
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The five sizes an icon may take. Each console names the same ladder in its
3
+ * own icon.ts, and the consistency suite holds every literal size in the kit
4
+ * to this list.
5
+ */
6
+ export declare const ICON_LADDER: readonly [12, 14, 16, 20, 40];
7
+ /**
8
+ * A rung of the icon ladder. A size a component looks up in a table is typed
9
+ * with it, so an entry off the ladder fails the type check where the suite
10
+ * cannot read a number held in a variable.
11
+ */
12
+ export type IconPx = (typeof ICON_LADDER)[number];
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The five sizes an icon may take. Each console names the same ladder in its
3
+ * own icon.ts, and the consistency suite holds every literal size in the kit
4
+ * to this list.
5
+ */
6
+ export const ICON_LADDER = [12, 14, 16, 20, 40];
@@ -93,6 +93,15 @@ export declare const PAGE_STACK = "flex flex-col gap-section";
93
93
  * crops the ring of every button inside it down to whichever edge fits.
94
94
  */
95
95
  export declare const CARD_SURFACE = "bg-surface border border-line rounded-xl";
96
+ /**
97
+ * The card surface in the danger tone, for a group of destructive actions.
98
+ *
99
+ * The fill is danger mixed into surface rather than a translucent tint, so it
100
+ * is opaque and paints the same on the page ground and inside a card. Five
101
+ * percent keeps the controls of a form inside it on a ground they were drawn
102
+ * for. The contrast suite reads the share from this string.
103
+ */
104
+ export declare const PANEL_DANGER_SURFACE = "bg-[color-mix(in_srgb,var(--color-danger)_5%,var(--color-surface))] border border-danger/40 rounded-xl";
96
105
  /**
97
106
  * Card padding by name.
98
107
  *
@@ -104,6 +104,15 @@ export const PAGE_STACK = 'flex flex-col gap-section';
104
104
  * crops the ring of every button inside it down to whichever edge fits.
105
105
  */
106
106
  export const CARD_SURFACE = 'bg-surface border border-line rounded-xl';
107
+ /**
108
+ * The card surface in the danger tone, for a group of destructive actions.
109
+ *
110
+ * The fill is danger mixed into surface rather than a translucent tint, so it
111
+ * is opaque and paints the same on the page ground and inside a card. Five
112
+ * percent keeps the controls of a form inside it on a ground they were drawn
113
+ * for. The contrast suite reads the share from this string.
114
+ */
115
+ export const PANEL_DANGER_SURFACE = 'bg-[color-mix(in_srgb,var(--color-danger)_5%,var(--color-surface))] border border-danger/40 rounded-xl';
107
116
  /**
108
117
  * Card padding by name.
109
118
  *
@@ -524,6 +524,12 @@ html[data-theme='light'] {
524
524
  *
525
525
  * Durations are not zeroed. An animation that finishes in 1ms still fires its
526
526
  * animationend, so components that wait on one keep working.
527
+ *
528
+ * The View Transitions API animates pseudo-elements on the root, and `*`
529
+ * matches none of them, so a page transition still cross-faded. They take a
530
+ * rule of their own because a browser that does not know a pseudo-element
531
+ * drops the whole rule it appears in, and the element rule has to survive
532
+ * there.
527
533
  */
528
534
  @media (prefers-reduced-motion: reduce) {
529
535
  *,
@@ -534,6 +540,14 @@ html[data-theme='light'] {
534
540
  transition-duration: 1ms !important;
535
541
  scroll-behavior: auto !important;
536
542
  }
543
+
544
+ ::view-transition-group(*),
545
+ ::view-transition-image-pair(*),
546
+ ::view-transition-old(*),
547
+ ::view-transition-new(*) {
548
+ animation-duration: 1ms !important;
549
+ animation-iteration-count: 1 !important;
550
+ }
537
551
  }
538
552
  }
539
553
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",
@@ -524,6 +524,12 @@ html[data-theme='light'] {
524
524
  *
525
525
  * Durations are not zeroed. An animation that finishes in 1ms still fires its
526
526
  * animationend, so components that wait on one keep working.
527
+ *
528
+ * The View Transitions API animates pseudo-elements on the root, and `*`
529
+ * matches none of them, so a page transition still cross-faded. They take a
530
+ * rule of their own because a browser that does not know a pseudo-element
531
+ * drops the whole rule it appears in, and the element rule has to survive
532
+ * there.
527
533
  */
528
534
  @media (prefers-reduced-motion: reduce) {
529
535
  *,
@@ -534,6 +540,14 @@ html[data-theme='light'] {
534
540
  transition-duration: 1ms !important;
535
541
  scroll-behavior: auto !important;
536
542
  }
543
+
544
+ ::view-transition-group(*),
545
+ ::view-transition-image-pair(*),
546
+ ::view-transition-old(*),
547
+ ::view-transition-new(*) {
548
+ animation-duration: 1ms !important;
549
+ animation-iteration-count: 1 !important;
550
+ }
537
551
  }
538
552
  }
539
553