@labcat2020/rocketship-svelte 0.0.0-stage → 0.2.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 +98 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/package.json +57 -3
- package/src/components/Accordion.svelte +89 -0
- package/src/components/Alert.svelte +129 -0
- package/src/components/Button.svelte +187 -0
- package/src/components/Card.svelte +124 -0
- package/src/components/Container.svelte +95 -0
- package/src/components/Disclosure.svelte +179 -0
- package/src/components/Field.svelte +183 -0
- package/src/components/Figure.svelte +128 -0
- package/src/components/Grid.svelte +62 -0
- package/src/components/Label.svelte +92 -0
- package/src/components/Table.svelte +193 -0
- package/src/components/Typography.svelte +205 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements'
|
|
4
|
+
|
|
5
|
+
export type CardTag = 'article' | 'section' | 'div' | 'li'
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
as?: CardTag
|
|
9
|
+
class?: string
|
|
10
|
+
media?: Snippet
|
|
11
|
+
meta?: Snippet
|
|
12
|
+
children?: Snippet
|
|
13
|
+
} & HTMLAttributes<HTMLElement>
|
|
14
|
+
|
|
15
|
+
let { as = 'article', class: className = '', media, meta, children, ...attrs }: Props = $props()
|
|
16
|
+
|
|
17
|
+
const classes = $derived(['rs-card', className].filter(Boolean).join(' '))
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<svelte:element this={as} {...attrs} class={classes}>
|
|
21
|
+
{#if media}
|
|
22
|
+
<div class="rs-card__media">
|
|
23
|
+
{@render media()}
|
|
24
|
+
</div>
|
|
25
|
+
{/if}
|
|
26
|
+
<div class="rs-card__body">
|
|
27
|
+
<div class="rs-card__content">
|
|
28
|
+
{@render children?.()}
|
|
29
|
+
</div>
|
|
30
|
+
{#if meta}
|
|
31
|
+
<div class="rs-card__meta">
|
|
32
|
+
{@render meta()}
|
|
33
|
+
</div>
|
|
34
|
+
{/if}
|
|
35
|
+
</div>
|
|
36
|
+
</svelte:element>
|
|
37
|
+
|
|
38
|
+
<style lang="scss">
|
|
39
|
+
@use '@labcat2020/rocketship/styles/mixins' as *;
|
|
40
|
+
|
|
41
|
+
:global {
|
|
42
|
+
.rs-card {
|
|
43
|
+
--rs-card-media-bg: var(--rs-color-surface-elevated, #eef2ff);
|
|
44
|
+
background: var(--rs-color-surface, #ffffff);
|
|
45
|
+
border: var(--rs-card-border-width, var(--rs-border-width-sm, 1px)) solid
|
|
46
|
+
var(--rs-card-border-color, var(--rs-color-border, #c8d2eb));
|
|
47
|
+
border-radius: var(--rs-card-border-radius, var(--rs-radius-md, 10px));
|
|
48
|
+
box-shadow: var(--rs-card-shadow, none);
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
|
|
51
|
+
&__media {
|
|
52
|
+
background: var(--rs-card-media-bg);
|
|
53
|
+
min-height: var(--rs-card-media-min-height, 96px);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&__body {
|
|
57
|
+
display: flex;
|
|
58
|
+
flex-direction: column;
|
|
59
|
+
gap: var(--rs-card-body-gap, var(--rs-space-3, 12px));
|
|
60
|
+
padding: var(--rs-card-body-padding, var(--rs-space-4, 16px));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
&__content {
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
gap: var(--rs-card-content-gap, var(--rs-space-1, 4px));
|
|
67
|
+
min-width: 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
&__meta {
|
|
71
|
+
display: flex;
|
|
72
|
+
flex-wrap: wrap;
|
|
73
|
+
gap: var(--rs-card-meta-gap, var(--rs-space-2, 8px));
|
|
74
|
+
min-width: 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
&__title {
|
|
78
|
+
color: var(--rs-color-fg, #131a2f);
|
|
79
|
+
font-family: var(
|
|
80
|
+
--rs-font-family-sans,
|
|
81
|
+
'Inter',
|
|
82
|
+
'Segoe UI',
|
|
83
|
+
Roboto,
|
|
84
|
+
Helvetica,
|
|
85
|
+
Arial,
|
|
86
|
+
sans-serif
|
|
87
|
+
);
|
|
88
|
+
font-size: var(--rs-font-size-lg, #{to-rem(18)});
|
|
89
|
+
font-weight: var(--rs-font-weight-semibold, 600);
|
|
90
|
+
line-height: var(--rs-line-height-tight, 1.2);
|
|
91
|
+
margin: 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
&__description {
|
|
95
|
+
color: var(--rs-color-muted, #4f5b7d);
|
|
96
|
+
font-family: var(
|
|
97
|
+
--rs-font-family-sans,
|
|
98
|
+
'Inter',
|
|
99
|
+
'Segoe UI',
|
|
100
|
+
Roboto,
|
|
101
|
+
Helvetica,
|
|
102
|
+
Arial,
|
|
103
|
+
sans-serif
|
|
104
|
+
);
|
|
105
|
+
font-size: var(--rs-font-size-md, #{to-rem(16)});
|
|
106
|
+
line-height: var(--rs-line-height-base, 1.45);
|
|
107
|
+
margin: 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&__token {
|
|
111
|
+
background: var(--rs-color-surface-elevated, #eef2ff);
|
|
112
|
+
border-radius: var(--rs-radius-sm, 6px);
|
|
113
|
+
color: var(--rs-color-muted, #4f5b7d);
|
|
114
|
+
display: inline-block;
|
|
115
|
+
font-family: var(--rs-font-family-mono, ui-monospace, monospace);
|
|
116
|
+
font-size: var(--rs-font-size-sm, #{to-rem(14)});
|
|
117
|
+
line-height: var(--rs-line-height-base, 1.45);
|
|
118
|
+
overflow-wrap: anywhere;
|
|
119
|
+
padding-block: 2px;
|
|
120
|
+
padding-inline: var(--rs-space-2, 8px);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements'
|
|
4
|
+
|
|
5
|
+
export type ContainerSize = 'default' | 'full' | 'wide' | 'content'
|
|
6
|
+
export type ContainerTag = 'div' | 'section' | 'main' | 'article' | 'header' | 'footer' | 'nav'
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
as?: ContainerTag
|
|
10
|
+
size?: ContainerSize
|
|
11
|
+
padded?: boolean
|
|
12
|
+
class?: string
|
|
13
|
+
children?: Snippet
|
|
14
|
+
} & HTMLAttributes<HTMLElement>
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
as = 'div',
|
|
18
|
+
size = 'default',
|
|
19
|
+
padded = false,
|
|
20
|
+
class: className = '',
|
|
21
|
+
children,
|
|
22
|
+
...attrs
|
|
23
|
+
}: Props = $props()
|
|
24
|
+
|
|
25
|
+
const classes = $derived(
|
|
26
|
+
[
|
|
27
|
+
'rs-container',
|
|
28
|
+
size === 'full' ? 'rs-container--full-width' : undefined,
|
|
29
|
+
size === 'wide' ? 'rs-container--wide' : undefined,
|
|
30
|
+
size === 'content' ? 'rs-container--content' : undefined,
|
|
31
|
+
padded ? 'rs-container--padded' : undefined,
|
|
32
|
+
className,
|
|
33
|
+
]
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join(' '),
|
|
36
|
+
)
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<svelte:element this={as} {...attrs} class={classes}>
|
|
40
|
+
{@render children?.()}
|
|
41
|
+
</svelte:element>
|
|
42
|
+
|
|
43
|
+
<style lang="scss">
|
|
44
|
+
@use '@labcat2020/rocketship/styles/mixins' as *;
|
|
45
|
+
|
|
46
|
+
:global {
|
|
47
|
+
.rs-container {
|
|
48
|
+
container-type: inline-size;
|
|
49
|
+
container-name: rs-component;
|
|
50
|
+
--rs-container-width: var(--rs-container-width-default, 1000px);
|
|
51
|
+
--rs-container-gutter-left: max(env(safe-area-inset-left), var(--rs-container-gutter, 16px));
|
|
52
|
+
--rs-container-gutter-right: max(env(safe-area-inset-right), var(--rs-container-gutter, 16px));
|
|
53
|
+
--rs-container-gutters: calc(
|
|
54
|
+
var(--rs-container-gutter-left) + var(--rs-container-gutter-right)
|
|
55
|
+
);
|
|
56
|
+
--rs-container-gutter-top: max(
|
|
57
|
+
env(safe-area-inset-top),
|
|
58
|
+
var(--rs-container-padding-block-start, 0px)
|
|
59
|
+
);
|
|
60
|
+
--rs-container-gutter-bottom: max(
|
|
61
|
+
env(safe-area-inset-bottom),
|
|
62
|
+
var(--rs-container-padding-block-end, 0px)
|
|
63
|
+
);
|
|
64
|
+
margin-inline: auto;
|
|
65
|
+
max-width: calc(var(--rs-container-width) + var(--rs-container-gutters));
|
|
66
|
+
padding-block: var(--rs-container-gutter-top) var(--rs-container-gutter-bottom);
|
|
67
|
+
padding-inline: var(--rs-container-gutter-left) var(--rs-container-gutter-right);
|
|
68
|
+
width: 100%;
|
|
69
|
+
|
|
70
|
+
&--content {
|
|
71
|
+
--rs-container-width: var(--rs-container-width-content-sm, 600px);
|
|
72
|
+
|
|
73
|
+
@include media-breakpoint-up(lg) {
|
|
74
|
+
--rs-container-width: var(--rs-container-width-content-lg, 800px);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
&--wide {
|
|
79
|
+
--rs-container-width: var(--rs-container-width-wide, 1200px);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
&--full-width {
|
|
83
|
+
--rs-container-width: 100%;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
&--padded {
|
|
87
|
+
padding-block: var(--rs-container-padding-block, 20px);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
&--no-gutters {
|
|
91
|
+
padding-inline: 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
</style>
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import { createContext } from 'svelte'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A disclosure registered with an Accordion. `isOpen` lets the accordion reconcile
|
|
6
|
+
* panels when it switches into single-open mode, and `close` is how it collapses a
|
|
7
|
+
* sibling.
|
|
8
|
+
*/
|
|
9
|
+
export type AccordionItem = { isOpen: () => boolean; close: () => void }
|
|
10
|
+
|
|
11
|
+
export type AccordionContext = {
|
|
12
|
+
register: (item: AccordionItem) => void
|
|
13
|
+
unregister: (item: AccordionItem) => void
|
|
14
|
+
opened: (item: AccordionItem) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const [getAccordion, setAccordion, hasAccordion] = createContext<AccordionContext>()
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import { untrack } from 'svelte'
|
|
22
|
+
import type { Snippet } from 'svelte'
|
|
23
|
+
import type { HTMLDetailsAttributes, ToggleEventHandler } from 'svelte/elements'
|
|
24
|
+
|
|
25
|
+
export type DisclosureTag = 'details'
|
|
26
|
+
|
|
27
|
+
type Props = {
|
|
28
|
+
as?: DisclosureTag
|
|
29
|
+
summary?: string
|
|
30
|
+
open?: boolean
|
|
31
|
+
class?: string
|
|
32
|
+
summarySlot?: Snippet
|
|
33
|
+
children?: Snippet
|
|
34
|
+
} & Omit<HTMLDetailsAttributes, 'open'>
|
|
35
|
+
|
|
36
|
+
let {
|
|
37
|
+
as = 'details',
|
|
38
|
+
summary,
|
|
39
|
+
open = $bindable(false),
|
|
40
|
+
class: className = '',
|
|
41
|
+
summarySlot,
|
|
42
|
+
children,
|
|
43
|
+
ontoggle,
|
|
44
|
+
...attrs
|
|
45
|
+
}: Props = $props()
|
|
46
|
+
|
|
47
|
+
const accordion = hasAccordion() ? getAccordion() : undefined
|
|
48
|
+
|
|
49
|
+
const self: AccordionItem = {
|
|
50
|
+
isOpen: () => open,
|
|
51
|
+
close: () => {
|
|
52
|
+
open = false
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
$effect(() => {
|
|
57
|
+
if (!accordion) return
|
|
58
|
+
accordion.register(self)
|
|
59
|
+
if (untrack(() => open)) accordion.opened(self)
|
|
60
|
+
return () => accordion.unregister(self)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// `<details>` fires `toggle` once its own open state has changed, so mirroring the
|
|
64
|
+
// element here is what gives consumers a working `bind:open`.
|
|
65
|
+
const onToggle: ToggleEventHandler<HTMLDetailsElement> = (event) => {
|
|
66
|
+
open = event.currentTarget.open
|
|
67
|
+
if (open) accordion?.opened(self)
|
|
68
|
+
ontoggle?.(event)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const classes = $derived(['rs-disclosure', className].filter(Boolean).join(' '))
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<svelte:element this={as} {...attrs} class={classes} open={open} ontoggle={onToggle}>
|
|
75
|
+
<summary class="rs-disclosure__summary">
|
|
76
|
+
<span class="rs-disclosure__label">
|
|
77
|
+
{#if summary}{summary}{:else if summarySlot}{@render summarySlot()}{/if}
|
|
78
|
+
</span>
|
|
79
|
+
<span class="rs-disclosure__indicator" aria-hidden="true"></span>
|
|
80
|
+
</summary>
|
|
81
|
+
<div class="rs-disclosure__panel">
|
|
82
|
+
{@render children?.()}
|
|
83
|
+
</div>
|
|
84
|
+
</svelte:element>
|
|
85
|
+
|
|
86
|
+
<style lang="scss">
|
|
87
|
+
@use '@labcat2020/rocketship/styles/mixins' as *;
|
|
88
|
+
|
|
89
|
+
:global {
|
|
90
|
+
.rs-disclosure {
|
|
91
|
+
--rs-disclosure-duration: var(--rs-transition-fast, 140ms);
|
|
92
|
+
--rs-disclosure-font-size: var(--rs-font-size-md, #{to-rem(16)});
|
|
93
|
+
border: var(--rs-disclosure-border-width, var(--rs-border-width-sm, 1px)) solid
|
|
94
|
+
var(--rs-disclosure-border-color, var(--rs-color-border, #c8d2eb));
|
|
95
|
+
border-radius: var(--rs-disclosure-border-radius, var(--rs-radius-md, 10px));
|
|
96
|
+
color: var(--rs-disclosure-color, var(--rs-color-fg, #131a2f));
|
|
97
|
+
font-family: var(
|
|
98
|
+
--rs-font-family-sans,
|
|
99
|
+
'Inter',
|
|
100
|
+
'Segoe UI',
|
|
101
|
+
Roboto,
|
|
102
|
+
Helvetica,
|
|
103
|
+
Arial,
|
|
104
|
+
sans-serif
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
&__summary {
|
|
108
|
+
align-items: center;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
display: flex;
|
|
111
|
+
font-size: var(--rs-disclosure-summary-font-size, var(--rs-disclosure-font-size));
|
|
112
|
+
font-weight: var(--rs-font-weight-medium, 500);
|
|
113
|
+
gap: var(--rs-space-2, 8px);
|
|
114
|
+
justify-content: space-between;
|
|
115
|
+
line-height: var(--rs-line-height-tight, 1.2);
|
|
116
|
+
list-style: none;
|
|
117
|
+
padding-block: var(--rs-disclosure-summary-padding-block, var(--rs-space-3, 12px));
|
|
118
|
+
padding-inline: var(--rs-disclosure-summary-padding-inline, var(--rs-space-4, 16px));
|
|
119
|
+
|
|
120
|
+
&::-webkit-details-marker {
|
|
121
|
+
display: none;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
&:focus-visible {
|
|
125
|
+
outline: 2px solid var(--rs-color-focus-ring, #296dff);
|
|
126
|
+
outline-offset: -2px;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
&__label {
|
|
131
|
+
min-width: 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
&__indicator {
|
|
135
|
+
border-color: currentcolor;
|
|
136
|
+
border-style: solid;
|
|
137
|
+
border-width: 0 var(--rs-border-width-md, 2px) var(--rs-border-width-md, 2px) 0;
|
|
138
|
+
flex: none;
|
|
139
|
+
height: 8px;
|
|
140
|
+
transform: rotate(45deg);
|
|
141
|
+
transition: transform var(--rs-disclosure-duration) ease;
|
|
142
|
+
width: 8px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
&__panel {
|
|
146
|
+
font-size: var(--rs-disclosure-panel-font-size, var(--rs-disclosure-font-size));
|
|
147
|
+
opacity: 0;
|
|
148
|
+
padding-block: 0 var(--rs-disclosure-panel-padding-block, var(--rs-space-4, 16px));
|
|
149
|
+
padding-inline: var(--rs-disclosure-panel-padding-inline, var(--rs-space-4, 16px));
|
|
150
|
+
transform: translateY(-2px);
|
|
151
|
+
transition:
|
|
152
|
+
opacity var(--rs-disclosure-duration) ease-out,
|
|
153
|
+
transform var(--rs-disclosure-duration) ease-out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
&[open] {
|
|
157
|
+
.rs-disclosure__indicator {
|
|
158
|
+
transform: rotate(-135deg);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.rs-disclosure__panel {
|
|
162
|
+
opacity: 1;
|
|
163
|
+
transform: translateY(0);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@include container-query-up($rs-cq-sm) {
|
|
168
|
+
--rs-disclosure-font-size: var(--rs-font-size-lg, #{to-rem(18)});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@media (prefers-reduced-motion: reduce) {
|
|
172
|
+
.rs-disclosure__indicator,
|
|
173
|
+
.rs-disclosure__panel {
|
|
174
|
+
transition: none;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
</style>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements'
|
|
4
|
+
import Label from './Label.svelte'
|
|
5
|
+
|
|
6
|
+
export type FieldTag = 'div' | 'section'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Attributes Field derives from its own props so the wrapped control stays linked to
|
|
10
|
+
* the label, hint, and error text. Spread it onto the control inside the `children` snippet.
|
|
11
|
+
*/
|
|
12
|
+
export type FieldControlProps = {
|
|
13
|
+
id: string
|
|
14
|
+
'aria-describedby': string | undefined
|
|
15
|
+
'aria-invalid': 'true' | undefined
|
|
16
|
+
required: boolean | undefined
|
|
17
|
+
disabled: boolean | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Props = {
|
|
21
|
+
as?: FieldTag
|
|
22
|
+
id?: string
|
|
23
|
+
label?: string
|
|
24
|
+
hint?: string
|
|
25
|
+
error?: string
|
|
26
|
+
hintId?: string
|
|
27
|
+
errorId?: string
|
|
28
|
+
required?: boolean
|
|
29
|
+
optional?: boolean
|
|
30
|
+
requiredText?: string
|
|
31
|
+
optionalText?: string
|
|
32
|
+
disabled?: boolean
|
|
33
|
+
class?: string
|
|
34
|
+
labelSlot?: Snippet<[string]>
|
|
35
|
+
hintSlot?: Snippet
|
|
36
|
+
errorSlot?: Snippet
|
|
37
|
+
children?: Snippet<[FieldControlProps]>
|
|
38
|
+
} & HTMLAttributes<HTMLElement>
|
|
39
|
+
|
|
40
|
+
let {
|
|
41
|
+
as = 'div',
|
|
42
|
+
id: idProp,
|
|
43
|
+
label,
|
|
44
|
+
hint,
|
|
45
|
+
error,
|
|
46
|
+
hintId: hintIdProp,
|
|
47
|
+
errorId: errorIdProp,
|
|
48
|
+
required = false,
|
|
49
|
+
optional = false,
|
|
50
|
+
requiredText,
|
|
51
|
+
optionalText,
|
|
52
|
+
disabled = false,
|
|
53
|
+
class: className = '',
|
|
54
|
+
'aria-disabled': ariaDisabled,
|
|
55
|
+
labelSlot,
|
|
56
|
+
hintSlot,
|
|
57
|
+
errorSlot,
|
|
58
|
+
children,
|
|
59
|
+
...attrs
|
|
60
|
+
}: Props = $props()
|
|
61
|
+
|
|
62
|
+
const generatedId = $props.id()
|
|
63
|
+
const id = $derived(idProp ?? `rs-field-${generatedId}`)
|
|
64
|
+
const hintId = $derived(hintIdProp ?? `${id}-hint`)
|
|
65
|
+
const errorId = $derived(errorIdProp ?? `${id}-error`)
|
|
66
|
+
const hasLabel = $derived(Boolean(label ?? labelSlot))
|
|
67
|
+
const hasHint = $derived(Boolean(hint ?? hintSlot))
|
|
68
|
+
const hasError = $derived(Boolean(error ?? errorSlot))
|
|
69
|
+
|
|
70
|
+
const describedBy = $derived(
|
|
71
|
+
[hasHint ? hintId : undefined, hasError ? errorId : undefined].filter(Boolean).join(' ') ||
|
|
72
|
+
undefined,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const control = $derived({
|
|
76
|
+
id,
|
|
77
|
+
'aria-describedby': describedBy,
|
|
78
|
+
'aria-invalid': hasError ? 'true' : undefined,
|
|
79
|
+
required: required || undefined,
|
|
80
|
+
disabled: disabled || undefined,
|
|
81
|
+
} satisfies FieldControlProps)
|
|
82
|
+
|
|
83
|
+
const classes = $derived(
|
|
84
|
+
['rs-field', disabled ? 'rs-field--disabled' : undefined, className].filter(Boolean).join(' '),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const wrapperAriaDisabled = $derived(disabled ? 'true' : ariaDisabled)
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<svelte:element
|
|
91
|
+
this={as}
|
|
92
|
+
{...attrs}
|
|
93
|
+
class={classes}
|
|
94
|
+
aria-disabled={wrapperAriaDisabled}
|
|
95
|
+
>
|
|
96
|
+
{#if hasLabel}
|
|
97
|
+
<div class="rs-field__label">
|
|
98
|
+
{#if label}
|
|
99
|
+
<Label for={id} {required} {optional} {requiredText} {optionalText}>{label}</Label>
|
|
100
|
+
{:else if labelSlot}
|
|
101
|
+
{@render labelSlot(id)}
|
|
102
|
+
{/if}
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
105
|
+
<div class="rs-field__control">
|
|
106
|
+
{@render children?.(control)}
|
|
107
|
+
</div>
|
|
108
|
+
{#if hasHint}
|
|
109
|
+
<div class="rs-field__hint" id={hintId}>
|
|
110
|
+
{#if hint}
|
|
111
|
+
{hint}
|
|
112
|
+
{:else if hintSlot}
|
|
113
|
+
{@render hintSlot()}
|
|
114
|
+
{/if}
|
|
115
|
+
</div>
|
|
116
|
+
{/if}
|
|
117
|
+
{#if hasError}
|
|
118
|
+
<div class="rs-field__error" id={errorId}>
|
|
119
|
+
{#if error}
|
|
120
|
+
{error}
|
|
121
|
+
{:else if errorSlot}
|
|
122
|
+
{@render errorSlot()}
|
|
123
|
+
{/if}
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
</svelte:element>
|
|
127
|
+
|
|
128
|
+
<style lang="scss">
|
|
129
|
+
@use '@labcat2020/rocketship/styles/mixins' as *;
|
|
130
|
+
|
|
131
|
+
:global {
|
|
132
|
+
.rs-field {
|
|
133
|
+
container-name: rs-component;
|
|
134
|
+
container-type: inline-size;
|
|
135
|
+
display: flex;
|
|
136
|
+
flex-direction: column;
|
|
137
|
+
font-family: var(
|
|
138
|
+
--rs-font-family-sans,
|
|
139
|
+
'Inter',
|
|
140
|
+
'Segoe UI',
|
|
141
|
+
Roboto,
|
|
142
|
+
Helvetica,
|
|
143
|
+
Arial,
|
|
144
|
+
sans-serif
|
|
145
|
+
);
|
|
146
|
+
gap: var(--rs-field-gap, var(--rs-space-2, 8px));
|
|
147
|
+
min-width: 0;
|
|
148
|
+
|
|
149
|
+
@include container-query-up($rs-cq-sm) {
|
|
150
|
+
gap: var(--rs-field-gap, var(--rs-space-3, 12px));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
&--disabled {
|
|
154
|
+
.rs-field__label,
|
|
155
|
+
.rs-field__control,
|
|
156
|
+
.rs-field__hint,
|
|
157
|
+
.rs-field__error {
|
|
158
|
+
opacity: 0.55;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
&__control {
|
|
163
|
+
display: grid;
|
|
164
|
+
grid-template-columns: minmax(0, 1fr);
|
|
165
|
+
min-width: 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
&__hint {
|
|
169
|
+
color: var(--rs-field-hint-color, var(--rs-color-muted, #4f5b7d));
|
|
170
|
+
font-size: var(--rs-field-hint-font-size, var(--rs-font-size-sm, #{to-rem(14)}));
|
|
171
|
+
line-height: var(--rs-line-height-base, 1.45);
|
|
172
|
+
margin: 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
&__error {
|
|
176
|
+
color: var(--rs-field-error-color, var(--rs-color-danger-fg, #3d0a08));
|
|
177
|
+
font-size: var(--rs-field-error-font-size, var(--rs-font-size-sm, #{to-rem(14)}));
|
|
178
|
+
line-height: var(--rs-line-height-base, 1.45);
|
|
179
|
+
margin: 0;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
</style>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements'
|
|
4
|
+
|
|
5
|
+
export type FigureTag = 'figure' | 'div'
|
|
6
|
+
export type FigureVariant = 'default' | 'bordered'
|
|
7
|
+
export type FigureCaptionAlign = 'start' | 'center'
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
as?: FigureTag
|
|
11
|
+
variant?: FigureVariant
|
|
12
|
+
captionAlign?: FigureCaptionAlign
|
|
13
|
+
caption?: string
|
|
14
|
+
class?: string
|
|
15
|
+
captionSlot?: Snippet
|
|
16
|
+
children?: Snippet
|
|
17
|
+
} & HTMLAttributes<HTMLElement>
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
as = 'figure',
|
|
21
|
+
variant = 'default',
|
|
22
|
+
captionAlign = 'start',
|
|
23
|
+
caption,
|
|
24
|
+
class: className = '',
|
|
25
|
+
captionSlot,
|
|
26
|
+
children,
|
|
27
|
+
...attrs
|
|
28
|
+
}: Props = $props()
|
|
29
|
+
|
|
30
|
+
const hasCaptionText = $derived(Boolean(caption?.trim()))
|
|
31
|
+
const hasCaption = $derived(hasCaptionText || Boolean(captionSlot))
|
|
32
|
+
|
|
33
|
+
// `<figcaption>` is only valid inside `<figure>`, so the `div` root pairs the caption
|
|
34
|
+
// with `role="note"` instead of rendering an orphaned caption element.
|
|
35
|
+
const isFigure = $derived(as === 'figure')
|
|
36
|
+
|
|
37
|
+
const classes = $derived(
|
|
38
|
+
['rs-figure', variant === 'bordered' ? 'rs-figure--bordered' : undefined, className]
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.join(' '),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const captionClasses = $derived(
|
|
44
|
+
['rs-figure__caption', captionAlign === 'center' ? 'rs-figure__caption--center' : undefined]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join(' '),
|
|
47
|
+
)
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<svelte:element this={as} {...attrs} class={classes}>
|
|
51
|
+
<div class="rs-figure__media">
|
|
52
|
+
{@render children?.()}
|
|
53
|
+
</div>
|
|
54
|
+
{#if hasCaption}
|
|
55
|
+
<svelte:element
|
|
56
|
+
this={isFigure ? 'figcaption' : 'div'}
|
|
57
|
+
class={captionClasses}
|
|
58
|
+
role={isFigure ? undefined : 'note'}
|
|
59
|
+
>
|
|
60
|
+
{#if hasCaptionText}{caption}{/if}{#if captionSlot}{@render captionSlot()}{/if}
|
|
61
|
+
</svelte:element>
|
|
62
|
+
{/if}
|
|
63
|
+
</svelte:element>
|
|
64
|
+
|
|
65
|
+
<style lang="scss">
|
|
66
|
+
@use '@labcat2020/rocketship/styles/mixins' as *;
|
|
67
|
+
|
|
68
|
+
:global {
|
|
69
|
+
.rs-figure {
|
|
70
|
+
container-name: rs-component;
|
|
71
|
+
container-type: inline-size;
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-direction: column;
|
|
74
|
+
gap: var(--rs-figure-gap, var(--rs-space-3, 12px));
|
|
75
|
+
margin-block: 0 var(--rs-text-spacing-bottom, 16px);
|
|
76
|
+
margin-inline: 0;
|
|
77
|
+
|
|
78
|
+
&__media {
|
|
79
|
+
border-radius: var(--rs-figure-media-radius, var(--rs-radius-md, 10px));
|
|
80
|
+
display: block;
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
|
|
83
|
+
:is(img, picture, video, canvas, svg) {
|
|
84
|
+
display: block;
|
|
85
|
+
height: auto;
|
|
86
|
+
max-width: 100%;
|
|
87
|
+
width: 100%;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
table {
|
|
91
|
+
margin-bottom: 0;
|
|
92
|
+
width: 100%;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Only the bordered variant frames the media, so the border is declared on the
|
|
97
|
+
// modifier and the default variant stays unframed.
|
|
98
|
+
&--bordered .rs-figure__media {
|
|
99
|
+
border: var(--rs-figure-media-border-width, var(--rs-border-width-sm, 1px)) solid
|
|
100
|
+
var(--rs-figure-media-border-color, var(--rs-color-border, #c8d2eb));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
&__caption {
|
|
104
|
+
color: var(--rs-figure-caption-color, var(--rs-color-muted, #4f5b7d));
|
|
105
|
+
font-family: var(
|
|
106
|
+
--rs-font-family-sans,
|
|
107
|
+
'Inter',
|
|
108
|
+
'Segoe UI',
|
|
109
|
+
Roboto,
|
|
110
|
+
Helvetica,
|
|
111
|
+
Arial,
|
|
112
|
+
sans-serif
|
|
113
|
+
);
|
|
114
|
+
font-size: var(--rs-figure-caption-font-size, var(--rs-font-size-sm, #{to-rem(14)}));
|
|
115
|
+
line-height: var(--rs-figure-caption-line-height, var(--rs-line-height-base, 1.45));
|
|
116
|
+
text-align: start;
|
|
117
|
+
|
|
118
|
+
@include container-query-up($rs-cq-sm) {
|
|
119
|
+
font-size: var(--rs-figure-caption-font-size-sm, var(--rs-font-size-sm, #{to-rem(14)}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
&--center {
|
|
123
|
+
text-align: center;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
</style>
|