@lyeve-labs/ui-kit 0.39.1 → 0.41.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.
package/README.md CHANGED
@@ -28,7 +28,7 @@ No config file, no theme provider, no setup ceremony.
28
28
 
29
29
  ## What's in the box
30
30
 
31
- - **72 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
31
+ - **75 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
32
32
  - **Two themes:** Soft Dark (default) and Soft Light, switched by a single `data-theme` attribute on `<html>`.
33
33
  - **One CSS file:** `@lyeve-labs/ui-kit/styles.css` declares every token. The rest is just Tailwind.
34
34
  - **Svelte 5 native:** built on runes and snippets, fully typed end-to-end.
@@ -37,10 +37,10 @@ No config file, no theme provider, no setup ceremony.
37
37
  ## Component list
38
38
 
39
39
  <details>
40
- <summary>72 components, organized by purpose</summary>
40
+ <summary>75 components, organized by purpose</summary>
41
41
 
42
42
  **Layout and structure**
43
- Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView
43
+ Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView, SplitPane
44
44
 
45
45
  **Forms and inputs**
46
46
  Button, ButtonGroup, Input, PasswordInput, CopyField, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, FilterChips, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
@@ -55,7 +55,7 @@ Modal, Drawer, Tooltip
55
55
  Dialog, DialogContainer, ConfirmDialog (plus the `openDialog` and `confirm` services)
56
56
 
57
57
  **Feedback and status**
58
- Alert, Banner, Badge, Tag, Indicator, Progress, Spinner, Skeleton, CheckoutSteps, EmptyState, PluginCart, Stat, Kbd, CopyButton
58
+ Alert, Banner, RefusalNotice, GateNotice, Badge, Tag, Indicator, Progress, Spinner, Skeleton, CheckoutSteps, EmptyState, PluginCart, Stat, Kbd, CopyButton
59
59
 
60
60
  **Media**
61
61
  Avatar, AvatarGroup, Logo
@@ -400,7 +400,7 @@ This repo is a single-purpose component library. Nothing but `src/lib/`.
400
400
  ```
401
401
  src/
402
402
  └── lib/ # → published as @lyeve-labs/ui-kit
403
- ├── components/ # 72 .svelte files
403
+ ├── components/ # 75 .svelte files
404
404
  ├── stores/ # toast.svelte.ts
405
405
  ├── styles/ # theme.css (the one stylesheet)
406
406
  ├── utils/ # cn.ts, theme.ts
@@ -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 `labeledBy`.
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;
@@ -98,10 +102,12 @@
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' : ''}">
@@ -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 `labeledBy`.
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;
@@ -6,6 +6,7 @@
6
6
  FIELD_WRAP,
7
7
  describedBy,
8
8
  } from '../internal/field.js';
9
+ import { describeAccept } from '../internal/accept.js';
9
10
  interface Props {
10
11
  id?: string;
11
12
  name?: string;
@@ -33,6 +34,7 @@
33
34
  }: Props = $props();
34
35
 
35
36
  let dragOver = $state(false);
37
+ const types = $derived(describeAccept(accept ?? ''));
36
38
 
37
39
  function handleChange(e: Event & { currentTarget: HTMLInputElement }) {
38
40
  onchange?.(e.currentTarget.files);
@@ -93,7 +95,7 @@
93
95
  <div class="text-sm">
94
96
  <span class="{error ? 'text-danger' : 'text-brand'} font-medium">Click to upload</span>
95
97
  <span class="text-muted"> or drag & drop</span>
96
- {#if accept}<p class="mt-0.5 text-xs text-faint [overflow-wrap:anywhere]">{accept}</p>{/if}
98
+ {#if types}<p class="mt-0.5 text-xs text-faint [overflow-wrap:anywhere]">{types}</p>{/if}
97
99
  </div>
98
100
  <input
99
101
  type="file"
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import { RefreshCw } from '@lucide/svelte';
3
+ import type { Snippet } from 'svelte';
4
+ import Alert from './Alert.svelte';
5
+ import Button from './Button.svelte';
6
+ import type { Gate, GateWords } from '../internal/gate.js';
7
+
8
+ /**
9
+ * What a page shows in place of what a refused or failed read would have
10
+ * filled, the same way on every page. The page passes the gate it mapped
11
+ * from the server's answer and every sentence in its own words. Only the
12
+ * states that waiting can change offer to try again.
13
+ */
14
+ interface Props {
15
+ gate: Gate;
16
+ words: GateWords;
17
+ /** Drawn for a feature that is not enabled, given the link forward the gate carried. */
18
+ locked: Snippet<[forwardHref: string]>;
19
+ /** The address that asks for this page again, usually its own path and query. */
20
+ retryHref: string;
21
+ class?: string;
22
+ }
23
+
24
+ let { gate, words, locked, retryHref, class: klass = '' }: Props = $props();
25
+ </script>
26
+
27
+ {#snippet retry()}
28
+ <div class="mt-3">
29
+ <!-- A plain link works before the page hydrates. After it hydrates, a
30
+ SvelteKit router keeps the data it holds for an address that did not
31
+ change, so the link asks for a fresh document instead. Other hosts
32
+ ignore the attribute. -->
33
+ <Button variant="secondary" size="sm" href={retryHref} data-sveltekit-reload>
34
+ <RefreshCw size={14} aria-hidden="true" />
35
+ {words.retry}
36
+ </Button>
37
+ </div>
38
+ {/snippet}
39
+
40
+ {#if gate.state !== 'ok'}
41
+ <div class={klass} data-testid="gate-notice" data-state={gate.state}>
42
+ {#if gate.state === 'absent'}
43
+ <Alert tone="brand">{words.absent}</Alert>
44
+ {:else if gate.state === 'locked'}
45
+ {@render locked(gate.forwardHref)}
46
+ {:else if gate.state === 'full'}
47
+ <Alert tone="warn" title={words.full.title}>{words.full.body}</Alert>
48
+ {:else if gate.state === 'forbidden'}
49
+ <!-- Trying again cannot change a role, so this one offers nothing to press. -->
50
+ <Alert tone="warn">{words.forbidden}</Alert>
51
+ {:else if gate.state === 'busy'}
52
+ <Alert tone="warn" title={words.busy.title}>
53
+ <p>{words.busy.body}</p>
54
+ {@render retry()}
55
+ </Alert>
56
+ {:else if gate.state === 'unavailable'}
57
+ <Alert tone="warn" title={words.unavailable.title}>
58
+ <p>{words.unavailable.body}</p>
59
+ {@render retry()}
60
+ </Alert>
61
+ {:else if gate.state === 'error'}
62
+ <Alert tone="danger">
63
+ <p>{gate.message}</p>
64
+ {@render retry()}
65
+ </Alert>
66
+ {/if}
67
+ </div>
68
+ {/if}
@@ -0,0 +1,20 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { Gate, GateWords } from '../internal/gate.js';
3
+ /**
4
+ * What a page shows in place of what a refused or failed read would have
5
+ * filled, the same way on every page. The page passes the gate it mapped
6
+ * from the server's answer and every sentence in its own words. Only the
7
+ * states that waiting can change offer to try again.
8
+ */
9
+ interface Props {
10
+ gate: Gate;
11
+ words: GateWords;
12
+ /** Drawn for a feature that is not enabled, given the link forward the gate carried. */
13
+ locked: Snippet<[forwardHref: string]>;
14
+ /** The address that asks for this page again, usually its own path and query. */
15
+ retryHref: string;
16
+ class?: string;
17
+ }
18
+ declare const GateNotice: import("svelte").Component<Props, {}, "">;
19
+ type GateNotice = ReturnType<typeof GateNotice>;
20
+ export default GateNotice;
@@ -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;
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ import Alert from './Alert.svelte';
3
+ import Button from './Button.svelte';
4
+ import { isExternalHref } from '../internal/href.js';
5
+
6
+ /**
7
+ * A write the server refused, said the same way on every page: a title, the
8
+ * sentence that says why, the way forward when there is one, and a link that
9
+ * is always offered, such as the page where the setting is read. The page
10
+ * passes every word, so a lifted limit never reads as a stale one here.
11
+ */
12
+ interface NoticeLink {
13
+ href: string;
14
+ label: string;
15
+ }
16
+
17
+ interface Props {
18
+ title: string;
19
+ body: string;
20
+ /** Where the reader gets past the refusal, when the refusal names a place. */
21
+ forward?: NoticeLink | null;
22
+ /** The link offered on every refusal. */
23
+ link: NoticeLink;
24
+ /**
25
+ * True where the way forward is the one action on its surface. A surface
26
+ * holds one primary, and the notice usually sits in a drawer or a page that
27
+ * has its own Create or Save, so by default its links stay below that: the
28
+ * way forward secondary and the other link ghost.
29
+ */
30
+ lead?: boolean;
31
+ class?: string;
32
+ }
33
+
34
+ let { title, body, forward = null, link, lead = false, class: klass = '' }: Props = $props();
35
+
36
+ // A link to another origin opens in a tab of its own, so the page it opens
37
+ // cannot reach back into this one through window.opener.
38
+ const away = $derived(forward !== null && isExternalHref(forward.href));
39
+ </script>
40
+
41
+ <div class={klass} data-testid="refusal-notice">
42
+ <Alert tone="warn" {title}>
43
+ <p>{body}</p>
44
+ <div class="mt-3 flex flex-wrap gap-2">
45
+ {#if forward}
46
+ <Button
47
+ variant={lead ? 'primary' : 'secondary'}
48
+ size="sm"
49
+ href={forward.href}
50
+ target={away ? '_blank' : undefined}
51
+ rel={away ? 'noopener noreferrer' : undefined}
52
+ >
53
+ {forward.label}
54
+ </Button>
55
+ {/if}
56
+ <Button variant={forward && !lead ? 'ghost' : 'secondary'} size="sm" href={link.href}>
57
+ {link.label}
58
+ </Button>
59
+ </div>
60
+ </Alert>
61
+ </div>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A write the server refused, said the same way on every page: a title, the
3
+ * sentence that says why, the way forward when there is one, and a link that
4
+ * is always offered, such as the page where the setting is read. The page
5
+ * passes every word, so a lifted limit never reads as a stale one here.
6
+ */
7
+ interface NoticeLink {
8
+ href: string;
9
+ label: string;
10
+ }
11
+ interface Props {
12
+ title: string;
13
+ body: string;
14
+ /** Where the reader gets past the refusal, when the refusal names a place. */
15
+ forward?: NoticeLink | null;
16
+ /** The link offered on every refusal. */
17
+ link: NoticeLink;
18
+ /**
19
+ * True where the way forward is the one action on its surface. A surface
20
+ * holds one primary, and the notice usually sits in a drawer or a page that
21
+ * has its own Create or Save, so by default its links stay below that: the
22
+ * way forward secondary and the other link ghost.
23
+ */
24
+ lead?: boolean;
25
+ class?: string;
26
+ }
27
+ declare const RefusalNotice: import("svelte").Component<Props, {}, "">;
28
+ type RefusalNotice = ReturnType<typeof RefusalNotice>;
29
+ export default RefusalNotice;
@@ -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,7 +71,8 @@
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
77
  labeledBy?: string;
72
78
  /** Submits the value with the surrounding form, through a hidden input. */
@@ -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
  };
@@ -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,7 +56,8 @@ 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
62
  labeledBy?: string;
58
63
  /** Submits the value with the surrounding form, through a hidden input. */