@labcat2020/rocketship-svelte 0.1.0 → 0.3.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
@@ -2,7 +2,123 @@
2
2
 
3
3
  Svelte 5 port of the Rocketship component library. Components reuse the same design system as the Astro package (`@labcat2020/rocketship`): design tokens, the mixin barrel, and the base resets are shared, not duplicated.
4
4
 
5
- This package is scaffolded and exports nothing yet. Components are added as copy-style ports of their Astro originals.
5
+ Components are either copy-style ports of their Astro originals, or Svelte-first components with no Astro original yet. Either way they render the same BEM classes and follow the same props, token, `:global` style, container-query and a11y conventions, so an Astro version can be ported back from a Svelte-first component. A Svelte-first component is provisional — it may change once the matching Astro component's platinum standard lands, and Astro is the source of truth when the two diverge.
6
+
7
+ ## Components
8
+
9
+ | Component | Export | Astro original |
10
+ | ------------ | ----------------------------------------------------- | ------------------ |
11
+ | `Container` | `@labcat2020/rocketship-svelte/components/Container` | `Container.astro` |
12
+ | `Grid` | `@labcat2020/rocketship-svelte/components/Grid` | `Grid.astro` |
13
+ | `Card` | `@labcat2020/rocketship-svelte/components/Card` | `Card.astro` |
14
+ | `Typography` | `@labcat2020/rocketship-svelte/components/Typography` | `Typography.astro` |
15
+ | `Button` | `@labcat2020/rocketship-svelte/components/Button` | `Button.astro` |
16
+ | `Alert` | `@labcat2020/rocketship-svelte/components/Alert` | `Alert.astro` |
17
+ | `Table` | `@labcat2020/rocketship-svelte/components/Table` | — (Svelte-first) |
18
+ | `Field` | `@labcat2020/rocketship-svelte/components/Field` | — (Svelte-first) |
19
+ | `Label` | `@labcat2020/rocketship-svelte/components/Label` | — (Svelte-first) |
20
+ | `Input` | `@labcat2020/rocketship-svelte/components/Input` | — (Svelte-first) |
21
+ | `Textarea` | `@labcat2020/rocketship-svelte/components/Textarea` | — (Svelte-first) |
22
+ | `Select` | `@labcat2020/rocketship-svelte/components/Select` | — (Svelte-first) |
23
+ | `Disclosure` | `@labcat2020/rocketship-svelte/components/Disclosure` | — (Svelte-first) |
24
+ | `Accordion` | `@labcat2020/rocketship-svelte/components/Accordion` | — (Svelte-first) |
25
+ | `Figure` | `@labcat2020/rocketship-svelte/components/Figure` | — (Svelte-first) |
26
+
27
+ Import components from their `./components/*` subpaths. Those subpaths are the package's public surface; deep imports such as `@labcat2020/rocketship-svelte/src/components/Container.svelte` are not exported and will not resolve.
28
+
29
+ ```svelte
30
+ <script lang="ts">
31
+ import Container from '@labcat2020/rocketship-svelte/components/Container'
32
+ import Grid from '@labcat2020/rocketship-svelte/components/Grid'
33
+ import Card from '@labcat2020/rocketship-svelte/components/Card'
34
+ import Typography from '@labcat2020/rocketship-svelte/components/Typography'
35
+ import Button from '@labcat2020/rocketship-svelte/components/Button'
36
+ </script>
37
+
38
+ <Container as="main" size="content" padded>
39
+ <Typography as="article">
40
+ <h1>Title</h1>
41
+ <p>Body copy.</p>
42
+ </Typography>
43
+ <Grid columns={3}>
44
+ <Card>…</Card>
45
+ <Card>…</Card>
46
+ <Card>…</Card>
47
+ </Grid>
48
+ <Button variant="primary">Continue</Button>
49
+ </Container>
50
+ ```
51
+
52
+ Each component spreads unknown attributes onto its rendered element and composes an incoming `class` with its BEM block.
53
+
54
+ | Component | Props |
55
+ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
56
+ | `Container` | `as` (`div`, `section`, `main`, `article`, `header`, `footer`, `nav`), `size` (`default`, `content`, `wide`, `full`), `padded`, `class` |
57
+ | `Grid` | `as` (`div`, `ul`, `ol`), `columns` (`2`, `3`, `4`), `class` |
58
+ | `Card` | `as` (`article`, `section`, `div`, `li`), `media` snippet, `meta` snippet, `class` |
59
+ | `Typography` | `as` (`div`, `section`, `article`), `html`, `class` |
60
+ | `Button` | `as` (`button`, `a`), `href`, `variant` (`primary`, `secondary`, `ghost`), `size` (`small`, `medium`, `large`), `disabled`, `type`, `class` |
61
+ | `Alert` | `as` (`div`, `section`, `aside`, `article`), `variant` (`info`, `success`, `warning`, `error`), `title`, `titleSlot` snippet, `role`, `class` |
62
+ | `Input` | `type` (`text`, `email`, `password`, `number`, `search`, `url`, `tel`), `size` (`sm`, `md`, `lg`), `invalid`, `value` (`bind:value`), `class` |
63
+ | `Textarea` | `size` (`sm`, `md`, `lg`), `invalid`, `value` (`bind:value`), `class` |
64
+ | `Select` | `size` (`sm`, `md`, `lg`), `invalid`, `placeholder`, `value` (`bind:value`), `children` snippet, `class` |
65
+ | `Figure` | `as` (`figure`, `div`), `variant` (`default`, `bordered`), `captionAlign` (`start`, `center`), `caption`, `captionSlot` snippet, `class` |
66
+
67
+ ### `Typography` `html` prop
68
+
69
+ `Typography` keeps the `html` prop: pass a string and it renders as raw markup inside the wrapper, taking precedence over the `children` snippet. It is the Svelte equivalent of the Astro component's `set:html`.
70
+
71
+ **Sanitize before you pass it.** The prop renders trusted HTML verbatim and does not sanitize its input, so it is only safe for content you control or content already sanitized at the boundary. Never pass unsanitized user input, request data, or third-party HTML through it — run it through a sanitizer (for example DOMPurify) first, or render structured markup with the `children` snippet instead.
72
+
73
+ ### `Field`, `Input` and `Textarea`
74
+
75
+ `Field` hands each control its wiring through the `children` snippet: `id`, `aria-describedby`, `aria-invalid`, `required` and `disabled`. Spread that object onto `Input` or `Textarea` so the label, hint and error text stay linked to the control.
76
+
77
+ ```svelte
78
+ <script lang="ts">
79
+ import Field from '@labcat2020/rocketship-svelte/components/Field'
80
+ import Input from '@labcat2020/rocketship-svelte/components/Input'
81
+ import Textarea from '@labcat2020/rocketship-svelte/components/Textarea'
82
+
83
+ let email = $state('')
84
+ let notes = $state('')
85
+ </script>
86
+
87
+ <Field label="Work email" hint="We send deployment alerts here.">
88
+ {#snippet children(control)}
89
+ <Input {...control} type="email" bind:value={email} />
90
+ {/snippet}
91
+ </Field>
92
+
93
+ <Field label="Release notes" error="Add at least ten characters.">
94
+ {#snippet children(control)}
95
+ <Textarea {...control} rows="3" bind:value={notes} />
96
+ {/snippet}
97
+ </Field>
98
+ ```
99
+
100
+ Both controls also work standalone. `invalid` raises `aria-invalid` and applies the error styling, and a size modifier (`sm`, `md`, `lg`) sets the type scale and control height. A `Field` error flows into the control's `aria-invalid` and its modifier, so the styling follows the error text Field renders.
101
+
102
+ ### `Select`
103
+
104
+ `Select` wraps a native `<select>` and takes its options through the `children` snippet, plain `<option>` and `<optgroup>` included. There is no custom listbox, so keyboard, screen-reader and mobile behaviour are the platform's.
105
+
106
+ ```svelte
107
+ <script lang="ts">
108
+ import Select from '@labcat2020/rocketship-svelte/components/Select'
109
+
110
+ let team = $state('')
111
+ </script>
112
+
113
+ <Select bind:value={team} placeholder="Choose a team" name="team">
114
+ <optgroup label="Engineering">
115
+ <option value="frontend">Frontend</option>
116
+ <option value="backend">Backend</option>
117
+ </optgroup>
118
+ </Select>
119
+ ```
120
+
121
+ `placeholder` renders a disabled option with an empty value, so it reads as an unset field. Once a real option is chosen the placeholder cannot be selected again; a parent can still reset the field by writing `''`. `size` accepts `sm`, `md` or `lg`; a number is passed through to the native `size` attribute, which renders a listbox instead of a dropdown, in which case no chevron is drawn.
6
122
 
7
123
  ## Install
8
124
 
package/dist/index.d.ts CHANGED
@@ -1,2 +1,19 @@
1
- export declare const rocketshipSvelte: {};
1
+ export { default as Accordion } from './components/Accordion.svelte';
2
+ export { default as Disclosure } from './components/Disclosure.svelte';
3
+ export { default as Field } from './components/Field.svelte';
4
+ export { default as Figure } from './components/Figure.svelte';
5
+ export { default as Input } from './components/Input.svelte';
6
+ export { default as Label } from './components/Label.svelte';
7
+ export { default as Select } from './components/Select.svelte';
8
+ export { default as Table } from './components/Table.svelte';
9
+ export { default as Textarea } from './components/Textarea.svelte';
10
+ export type * from './components/Accordion.svelte';
11
+ export type * from './components/Disclosure.svelte';
12
+ export type * from './components/Field.svelte';
13
+ export type * from './components/Figure.svelte';
14
+ export type * from './components/Input.svelte';
15
+ export type * from './components/Label.svelte';
16
+ export type * from './components/Select.svelte';
17
+ export type * from './components/Table.svelte';
18
+ export type * from './components/Textarea.svelte';
2
19
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,gBAAgB,IAAK,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,+BAA+B,CAAA;AACpE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAA;AACtE,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,2BAA2B,CAAA;AAC5D,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,4BAA4B,CAAA;AAC9D,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,2BAA2B,CAAA;AAC5D,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,2BAA2B,CAAA;AAC5D,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,4BAA4B,CAAA;AAC9D,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,2BAA2B,CAAA;AAC5D,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,8BAA8B,CAAA;AAClE,mBAAmB,+BAA+B,CAAA;AAClD,mBAAmB,gCAAgC,CAAA;AACnD,mBAAmB,2BAA2B,CAAA;AAC9C,mBAAmB,4BAA4B,CAAA;AAC/C,mBAAmB,2BAA2B,CAAA;AAC9C,mBAAmB,2BAA2B,CAAA;AAC9C,mBAAmB,4BAA4B,CAAA;AAC/C,mBAAmB,2BAA2B,CAAA;AAC9C,mBAAmB,8BAA8B,CAAA"}
package/dist/index.js CHANGED
@@ -1 +1,10 @@
1
- export const rocketshipSvelte = {};
1
+ /// <reference types="svelte" />
2
+ export { default as Accordion } from './components/Accordion.svelte';
3
+ export { default as Disclosure } from './components/Disclosure.svelte';
4
+ export { default as Field } from './components/Field.svelte';
5
+ export { default as Figure } from './components/Figure.svelte';
6
+ export { default as Input } from './components/Input.svelte';
7
+ export { default as Label } from './components/Label.svelte';
8
+ export { default as Select } from './components/Select.svelte';
9
+ export { default as Table } from './components/Table.svelte';
10
+ export { default as Textarea } from './components/Textarea.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@labcat2020/rocketship-svelte",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Rocketship Svelte port — Svelte 5 components that reuse the Rocketship design system (tokens, mixins, base resets)",
@@ -18,19 +18,38 @@
18
18
  "types": "./dist/index.d.ts",
19
19
  "import": "./dist/index.js",
20
20
  "default": "./dist/index.js"
21
- }
21
+ },
22
+ "./components/Alert": "./src/components/Alert.svelte",
23
+ "./components/Accordion": "./src/components/Accordion.svelte",
24
+ "./components/Button": "./src/components/Button.svelte",
25
+ "./components/Card": "./src/components/Card.svelte",
26
+ "./components/Container": "./src/components/Container.svelte",
27
+ "./components/Disclosure": "./src/components/Disclosure.svelte",
28
+ "./components/Field": "./src/components/Field.svelte",
29
+ "./components/Figure": "./src/components/Figure.svelte",
30
+ "./components/Grid": "./src/components/Grid.svelte",
31
+ "./components/Input": "./src/components/Input.svelte",
32
+ "./components/Label": "./src/components/Label.svelte",
33
+ "./components/Select": "./src/components/Select.svelte",
34
+ "./components/Table": "./src/components/Table.svelte",
35
+ "./components/Textarea": "./src/components/Textarea.svelte",
36
+ "./components/Typography": "./src/components/Typography.svelte"
22
37
  },
23
38
  "files": [
24
39
  "dist",
40
+ "src/components",
25
41
  "README.md"
26
42
  ],
27
43
  "publishConfig": {
28
44
  "access": "public"
29
45
  },
30
46
  "dependencies": {
31
- "@labcat2020/rocketship": "0.1.0"
47
+ "@labcat2020/rocketship": "0.2.0"
32
48
  },
33
49
  "devDependencies": {
50
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
51
+ "axe-core": "4.11.1",
52
+ "jsdom": "^30.1.2",
34
53
  "sass": "^1.105.1",
35
54
  "svelte": "^5.57.2",
36
55
  "svelte-check": "^4.7.6",
@@ -0,0 +1,89 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte'
3
+ import type { Snippet } from 'svelte'
4
+ import type { HTMLAttributes } from 'svelte/elements'
5
+ import { setAccordion, type AccordionItem } from './Disclosure.svelte'
6
+
7
+ export type AccordionMode = 'multiple' | 'single'
8
+ export type AccordionTag = 'div' | 'section' | 'article' | 'ul'
9
+
10
+ type Props = {
11
+ as?: AccordionTag
12
+ mode?: AccordionMode
13
+ class?: string
14
+ children?: Snippet
15
+ } & HTMLAttributes<HTMLElement>
16
+
17
+ let {
18
+ as = 'div',
19
+ mode = 'multiple',
20
+ class: className = '',
21
+ children,
22
+ ...attrs
23
+ }: Props = $props()
24
+
25
+ const items = new Set<AccordionItem>()
26
+ let lastOpened: AccordionItem | undefined
27
+
28
+ const closeSiblings = (item: AccordionItem) => {
29
+ if (mode !== 'single') return
30
+ for (const other of items) {
31
+ if (other !== item) other.close()
32
+ }
33
+ }
34
+
35
+ setAccordion({
36
+ register: (item) => {
37
+ items.add(item)
38
+ },
39
+ unregister: (item) => {
40
+ items.delete(item)
41
+ if (lastOpened === item) lastOpened = undefined
42
+ },
43
+ opened: (item) => {
44
+ lastOpened = item
45
+ closeSiblings(item)
46
+ },
47
+ })
48
+
49
+ // Switching to single mode at runtime must reconcile panels that are already open,
50
+ // keeping the most recently opened one. `untrack` avoids subscribing to child state.
51
+ $effect(() => {
52
+ if (mode !== 'single') return
53
+ untrack(() => {
54
+ const open = [...items].filter((item) => item.isOpen())
55
+ if (open.length <= 1) return
56
+ const keep = lastOpened && open.includes(lastOpened) ? lastOpened : open[open.length - 1]
57
+ for (const item of open) {
58
+ if (item !== keep) item.close()
59
+ }
60
+ })
61
+ })
62
+
63
+ const classes = $derived(
64
+ ['rs-accordion', mode === 'single' ? 'rs-accordion--single' : undefined, className]
65
+ .filter(Boolean)
66
+ .join(' '),
67
+ )
68
+ </script>
69
+
70
+ <svelte:element this={as} {...attrs} class={classes}>
71
+ {@render children?.()}
72
+ </svelte:element>
73
+
74
+ <style lang="scss">
75
+ @use '@labcat2020/rocketship/styles/mixins' as *;
76
+
77
+ :global {
78
+ .rs-accordion {
79
+ --rs-accordion-gap: var(--rs-space-2, 8px);
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: var(--rs-accordion-gap);
83
+
84
+ &--single {
85
+ --rs-accordion-gap: 0;
86
+ }
87
+ }
88
+ }
89
+ </style>
@@ -0,0 +1,129 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import type { HTMLAttributes } from 'svelte/elements'
4
+
5
+ export type AlertVariant = 'info' | 'success' | 'warning' | 'error'
6
+ export type AlertTag = 'div' | 'section' | 'aside' | 'article'
7
+
8
+ type Props = {
9
+ as?: AlertTag
10
+ variant?: AlertVariant
11
+ title?: string
12
+ role?: 'alert' | 'status'
13
+ class?: string
14
+ titleSlot?: Snippet
15
+ children?: Snippet
16
+ } & HTMLAttributes<HTMLElement>
17
+
18
+ let {
19
+ as = 'div',
20
+ variant = 'info',
21
+ title,
22
+ role: roleProp,
23
+ class: className = '',
24
+ titleSlot,
25
+ children,
26
+ ...attrs
27
+ }: Props = $props()
28
+
29
+ const role = $derived(roleProp ?? (variant === 'error' || variant === 'warning' ? 'alert' : 'status'))
30
+
31
+ const classes = $derived(['rs-alert', `rs-alert--${variant}`, className].filter(Boolean).join(' '))
32
+ </script>
33
+
34
+ <svelte:element this={as} {...attrs} class={classes} role={role}>
35
+ {#if title}
36
+ <h4 class="rs-alert__title">{title}</h4>
37
+ {:else if titleSlot}
38
+ <h4 class="rs-alert__title">
39
+ {@render titleSlot()}
40
+ </h4>
41
+ {/if}
42
+ <div class="rs-alert__body">
43
+ {@render children?.()}
44
+ </div>
45
+ </svelte:element>
46
+
47
+ <style lang="scss">
48
+ @use '@labcat2020/rocketship/styles/mixins' as *;
49
+
50
+ :global {
51
+ .rs-alert {
52
+ --rs-alert-bg: var(--rs-color-surface-elevated, #eef2ff);
53
+ --rs-alert-color: var(--rs-color-fg, #131a2f);
54
+ --rs-alert-border-color: var(--rs-color-border, #c8d2eb);
55
+ --rs-alert-accent-color: var(--rs-color-accent, #2f5bdb);
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: var(--rs-alert-gap, var(--rs-space-2, 8px));
59
+ font-family: var(
60
+ --rs-font-family-sans,
61
+ 'Inter',
62
+ 'Segoe UI',
63
+ Roboto,
64
+ Helvetica,
65
+ Arial,
66
+ sans-serif
67
+ );
68
+ background: var(--rs-alert-bg);
69
+ border: var(--rs-alert-border-width, var(--rs-border-width-sm, 1px)) solid
70
+ var(--rs-alert-border-color);
71
+ border-inline-start: var(--rs-alert-accent-width, var(--rs-border-width-lg, 4px)) solid
72
+ var(--rs-alert-accent-color);
73
+ border-radius: var(--rs-alert-border-radius, var(--rs-radius-md, 10px));
74
+ color: var(--rs-alert-color);
75
+ padding-block: var(--rs-alert-padding-block, var(--rs-space-3, 12px));
76
+ padding-inline: var(--rs-alert-padding-inline, var(--rs-space-4, 16px));
77
+
78
+ @include container-query-up($rs-cq-sm) {
79
+ gap: var(--rs-alert-gap, var(--rs-space-3, 12px));
80
+ padding-block: var(--rs-alert-padding-block, var(--rs-space-4, 16px));
81
+ padding-inline: var(--rs-alert-padding-inline, var(--rs-space-5, 20px));
82
+ }
83
+
84
+ &--success {
85
+ --rs-alert-bg: var(--rs-color-success-bg, #e6f4ea);
86
+ --rs-alert-border-color: var(--rs-color-success-border, #a7d8b5);
87
+ --rs-alert-accent-color: var(--rs-color-success-accent, #1a7a32);
88
+ --rs-alert-color: var(--rs-color-success-fg, #0f2a18);
89
+ }
90
+
91
+ &--warning {
92
+ --rs-alert-bg: var(--rs-color-warning-bg, #fff8e1);
93
+ --rs-alert-border-color: var(--rs-color-warning-border, #f0c36a);
94
+ --rs-alert-accent-color: var(--rs-color-warning-accent, #9a5d00);
95
+ --rs-alert-color: var(--rs-color-warning-fg, #3d2800);
96
+ }
97
+
98
+ &--error {
99
+ --rs-alert-bg: var(--rs-color-danger-bg, #ffeef0);
100
+ --rs-alert-border-color: var(--rs-color-danger-border, #f0a8b0);
101
+ --rs-alert-accent-color: var(--rs-color-danger-accent, #c5221f);
102
+ --rs-alert-color: var(--rs-color-danger-fg, #3d0a08);
103
+ }
104
+
105
+ &__title {
106
+ color: var(--rs-alert-color);
107
+ font-size: var(--rs-alert-title-font-size, var(--rs-font-size-md, #{to-rem(16)}));
108
+ font-weight: var(--rs-alert-title-font-weight, var(--rs-font-weight-semibold, 600));
109
+ line-height: var(--rs-alert-title-line-height, var(--rs-line-height-tight, 1.2));
110
+ margin: 0;
111
+
112
+ @include container-query-up($rs-cq-sm) {
113
+ font-size: var(--rs-alert-title-font-size, var(--rs-font-size-lg, #{to-rem(18)}));
114
+ }
115
+ }
116
+
117
+ &__body {
118
+ color: var(--rs-alert-color);
119
+ font-size: var(--rs-alert-body-font-size, var(--rs-font-size-sm, #{to-rem(14)}));
120
+ line-height: var(--rs-alert-body-line-height, var(--rs-line-height-base, 1.45));
121
+ margin: 0;
122
+
123
+ @include container-query-up($rs-cq-sm) {
124
+ font-size: var(--rs-alert-body-font-size, var(--rs-font-size-md, #{to-rem(16)}));
125
+ }
126
+ }
127
+ }
128
+ }
129
+ </style>
@@ -0,0 +1,187 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'
4
+
5
+ export type ButtonVariant = 'primary' | 'secondary' | 'ghost'
6
+ export type ButtonSize = 'small' | 'medium' | 'large'
7
+ export type ButtonTag = 'button' | 'a'
8
+
9
+ type NativeButtonType = 'button' | 'submit' | 'reset'
10
+
11
+ type Props = {
12
+ as?: ButtonTag
13
+ href?: string
14
+ variant?: ButtonVariant
15
+ size?: ButtonSize
16
+ disabled?: boolean
17
+ type?: NativeButtonType
18
+ class?: string
19
+ children?: Snippet
20
+ } & HTMLButtonAttributes &
21
+ HTMLAnchorAttributes
22
+
23
+ let {
24
+ as = 'button',
25
+ href,
26
+ variant = 'primary',
27
+ size = 'medium',
28
+ disabled = false,
29
+ type = 'button',
30
+ class: className = '',
31
+ children,
32
+ ...attrs
33
+ }: Props = $props()
34
+
35
+ const classes = $derived(
36
+ ['rs-button', `rs-button--${variant}`, `rs-button--${size}`, className].filter(Boolean).join(' '),
37
+ )
38
+
39
+ const shouldRenderAsLink = $derived(as === 'a' || Boolean(href))
40
+ </script>
41
+
42
+ {#if shouldRenderAsLink}
43
+ <a
44
+ {...attrs}
45
+ aria-disabled={disabled || undefined}
46
+ class={classes}
47
+ href={disabled ? undefined : (href ?? '#')}
48
+ role={disabled ? 'link' : undefined}
49
+ tabindex={disabled ? -1 : undefined}
50
+ >
51
+ {@render children?.()}
52
+ </a>
53
+ {:else}
54
+ <button {...attrs} class={classes} disabled={disabled} type={type}>
55
+ {@render children?.()}
56
+ </button>
57
+ {/if}
58
+
59
+ <style lang="scss">
60
+ @use '@labcat2020/rocketship/styles/mixins' as *;
61
+
62
+ :global {
63
+ .rs-button {
64
+ all: unset;
65
+ --rs-button-bg: var(--rs-color-accent, #2f5bdb);
66
+ --rs-button-color: var(--rs-color-accent-contrast, #ffffff);
67
+ --rs-button-border-color: var(--rs-color-accent, #2f5bdb);
68
+ --rs-button-border-width: var(--rs-border-width, 1px);
69
+ --rs-button-border-radius: var(--rs-radius-pill, 999px);
70
+ --rs-button-font-size: var(--rs-font-size-sm, #{to-rem(14)});
71
+ --rs-button-font-weight: var(--rs-font-weight-semibold, 600);
72
+ --rs-button-line-height: var(--rs-line-height-tight, 1.2);
73
+ --rs-button-padding-inline: var(--rs-space-3, 12px);
74
+ --rs-button-min-height: 36px;
75
+ --rs-button-gap: var(--rs-space-2, 8px);
76
+ --rs-button-focus-ring-color: var(--rs-color-focus-ring, #296dff);
77
+ box-sizing: border-box;
78
+ align-items: center;
79
+ background: var(--rs-button-bg);
80
+ border: var(--rs-button-border-width) solid var(--rs-button-border-color);
81
+ border-radius: var(--rs-button-border-radius);
82
+ color: var(--rs-button-color);
83
+ cursor: pointer;
84
+ display: inline-flex;
85
+ font-family: var(
86
+ --rs-font-family-sans,
87
+ 'Inter',
88
+ 'Segoe UI',
89
+ Roboto,
90
+ Helvetica,
91
+ Arial,
92
+ sans-serif
93
+ );
94
+ font-size: var(--rs-button-font-size);
95
+ font-weight: var(--rs-button-font-weight);
96
+ gap: var(--rs-button-gap);
97
+ justify-content: center;
98
+ line-height: var(--rs-button-line-height);
99
+ min-height: var(--rs-button-min-height);
100
+ padding-block: 8px;
101
+ padding-inline: var(--rs-button-padding-inline);
102
+ text-decoration: none;
103
+ transition:
104
+ background-color var(--rs-transition-fast, 140ms) ease,
105
+ border-color var(--rs-transition-fast, 140ms) ease,
106
+ color var(--rs-transition-fast, 140ms) ease,
107
+ opacity var(--rs-transition-fast, 140ms) ease;
108
+ white-space: nowrap;
109
+
110
+ &:hover {
111
+ filter: brightness(1.08);
112
+ }
113
+
114
+ &:active {
115
+ filter: brightness(0.94);
116
+ }
117
+
118
+ &:focus-visible {
119
+ outline: 2px solid var(--rs-button-focus-ring-color);
120
+ outline-offset: 2px;
121
+ }
122
+
123
+ &:disabled,
124
+ &[aria-disabled='true'] {
125
+ cursor: not-allowed;
126
+ opacity: 0.55;
127
+ pointer-events: none;
128
+ }
129
+
130
+ &--secondary {
131
+ --rs-button-bg: var(--rs-color-surface, #ffffff);
132
+ --rs-button-border-color: var(--rs-color-accent, #2f5bdb);
133
+ --rs-button-border-width: var(--rs-border-width-md, 2px);
134
+ --rs-button-color: var(--rs-color-accent, #2f5bdb);
135
+
136
+ &:hover {
137
+ filter: none;
138
+ --rs-button-bg: var(--rs-color-surface-elevated, #eef2ff);
139
+ --rs-button-border-color: var(--rs-color-link-hover, #173ea8);
140
+ --rs-button-color: var(--rs-color-link-hover, #173ea8);
141
+ }
142
+ }
143
+
144
+ &--ghost {
145
+ --rs-button-bg: transparent;
146
+ --rs-button-border-color: transparent;
147
+ --rs-button-border-width: var(--rs-border-width-md, 2px);
148
+ --rs-button-color: var(--rs-color-accent, #2f5bdb);
149
+
150
+ &:hover {
151
+ filter: none;
152
+ --rs-button-bg: var(--rs-color-surface-elevated, #eef2ff);
153
+ }
154
+ }
155
+
156
+ &--small {
157
+ --rs-button-font-size: var(--rs-button-font-size-sm);
158
+ --rs-button-min-height: var(--rs-button-min-height-sm);
159
+ --rs-button-padding-inline: var(--rs-button-padding-inline-sm);
160
+ }
161
+
162
+ &--medium {
163
+ --rs-button-font-size: var(--rs-button-font-size-md);
164
+ --rs-button-min-height: var(--rs-button-min-height-md);
165
+ --rs-button-padding-inline: var(--rs-button-padding-inline-md);
166
+ }
167
+
168
+ &--large {
169
+ --rs-button-font-size: var(--rs-button-font-size-lg);
170
+ --rs-button-min-height: var(--rs-button-min-height-lg);
171
+ --rs-button-padding-inline: var(--rs-button-padding-inline-lg);
172
+ }
173
+
174
+ @include container-query-up($rs-cq-sm) {
175
+ --rs-button-font-size-sm: var(--rs-button-font-size-sm-cq);
176
+ --rs-button-font-size-md: var(--rs-button-font-size-md-cq);
177
+ --rs-button-font-size-lg: var(--rs-button-font-size-lg-cq);
178
+ --rs-button-min-height-sm: var(--rs-button-min-height-sm-cq);
179
+ --rs-button-min-height-md: var(--rs-button-min-height-md-cq);
180
+ --rs-button-min-height-lg: var(--rs-button-min-height-lg-cq);
181
+ --rs-button-padding-inline-sm: var(--rs-button-padding-inline-sm-cq);
182
+ --rs-button-padding-inline-md: var(--rs-button-padding-inline-md-cq);
183
+ --rs-button-padding-inline-lg: var(--rs-button-padding-inline-lg-cq);
184
+ }
185
+ }
186
+ }
187
+ </style>