@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 +5 -5
- package/dist/components/Field.svelte +12 -6
- package/dist/components/Field.svelte.d.ts +6 -2
- package/dist/components/FileInput.svelte +3 -1
- package/dist/components/GateNotice.svelte +68 -0
- package/dist/components/GateNotice.svelte.d.ts +20 -0
- package/dist/components/Panel.svelte +38 -8
- package/dist/components/Panel.svelte.d.ts +13 -0
- package/dist/components/RefusalNotice.svelte +61 -0
- package/dist/components/RefusalNotice.svelte.d.ts +29 -0
- package/dist/components/SegmentedControl.svelte +9 -3
- package/dist/components/SegmentedControl.svelte.d.ts +7 -2
- package/dist/components/SplitPane.svelte +428 -0
- package/dist/components/SplitPane.svelte.d.ts +46 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -1
- package/dist/internal/accept.d.ts +13 -0
- package/dist/internal/accept.js +76 -0
- package/dist/internal/aside.d.ts +36 -0
- package/dist/internal/aside.js +61 -0
- package/dist/internal/choice.d.ts +4 -2
- package/dist/internal/choice.js +2 -1
- package/dist/internal/gate.d.ts +71 -0
- package/dist/internal/gate.js +1 -0
- package/dist/internal/href.d.ts +8 -0
- package/dist/internal/href.js +18 -0
- package/dist/internal/icon.d.ts +12 -0
- package/dist/internal/icon.js +6 -0
- package/dist/internal/layout.d.ts +9 -0
- package/dist/internal/layout.js +9 -0
- package/dist/styles/theme.css +14 -0
- package/package.json +6 -2
- package/src/lib/styles/theme.css +14 -0
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
|
-
- **
|
|
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>
|
|
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/ #
|
|
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,
|
|
52
|
-
* name from it
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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,
|
|
40
|
-
* name from it
|
|
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
|
|
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 {
|
|
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"
|
|
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="{
|
|
117
|
+
<h2 class="{headingClass(2)} flex items-center gap-2">{@render headingText()}</h2>
|
|
88
118
|
{:else if heading}
|
|
89
|
-
<h3 class="{
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
/**
|
|
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. */
|