@labcat2020/rocketship-svelte 0.1.0 → 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 +61 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/package.json +19 -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,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>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements'
|
|
4
|
+
|
|
5
|
+
export type GridColumns = 2 | 3 | 4
|
|
6
|
+
export type GridTag = 'div' | 'ul' | 'ol'
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
as?: GridTag
|
|
10
|
+
columns?: GridColumns
|
|
11
|
+
class?: string
|
|
12
|
+
children?: Snippet
|
|
13
|
+
} & HTMLAttributes<HTMLElement>
|
|
14
|
+
|
|
15
|
+
let { as = 'div', columns = 2, class: className = '', children, ...attrs }: Props = $props()
|
|
16
|
+
|
|
17
|
+
const classes = $derived(
|
|
18
|
+
[
|
|
19
|
+
'rs-grid',
|
|
20
|
+
columns === 3 ? 'rs-grid--3-cols' : undefined,
|
|
21
|
+
columns === 4 ? 'rs-grid--4-cols' : undefined,
|
|
22
|
+
className,
|
|
23
|
+
]
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join(' '),
|
|
26
|
+
)
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<svelte:element this={as} {...attrs} class={classes}>
|
|
30
|
+
{@render children?.()}
|
|
31
|
+
</svelte:element>
|
|
32
|
+
|
|
33
|
+
<style lang="scss">
|
|
34
|
+
@use '@labcat2020/rocketship/styles/mixins' as *;
|
|
35
|
+
|
|
36
|
+
:global {
|
|
37
|
+
.rs-grid {
|
|
38
|
+
display: grid;
|
|
39
|
+
gap: var(--rs-grid-gap, var(--rs-space-4, 16px));
|
|
40
|
+
grid-template-columns: minmax(0, 1fr);
|
|
41
|
+
list-style: none;
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: 0;
|
|
44
|
+
|
|
45
|
+
@include container-query-up($rs-cq-sm) {
|
|
46
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&--3-cols {
|
|
50
|
+
@include container-query-up($rs-cq-md) {
|
|
51
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
&--4-cols {
|
|
56
|
+
@include container-query-up($rs-cq-md) {
|
|
57
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
</style>
|