@labcat2020/rocketship-svelte 0.2.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 +56 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/package.json +4 -1
- package/src/components/Input.svelte +138 -0
- package/src/components/Select.svelte +235 -0
- package/src/components/Textarea.svelte +138 -0
package/README.md
CHANGED
|
@@ -17,6 +17,9 @@ Components are either copy-style ports of their Astro originals, or Svelte-first
|
|
|
17
17
|
| `Table` | `@labcat2020/rocketship-svelte/components/Table` | — (Svelte-first) |
|
|
18
18
|
| `Field` | `@labcat2020/rocketship-svelte/components/Field` | — (Svelte-first) |
|
|
19
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) |
|
|
20
23
|
| `Disclosure` | `@labcat2020/rocketship-svelte/components/Disclosure` | — (Svelte-first) |
|
|
21
24
|
| `Accordion` | `@labcat2020/rocketship-svelte/components/Accordion` | — (Svelte-first) |
|
|
22
25
|
| `Figure` | `@labcat2020/rocketship-svelte/components/Figure` | — (Svelte-first) |
|
|
@@ -56,6 +59,9 @@ Each component spreads unknown attributes onto its rendered element and composes
|
|
|
56
59
|
| `Typography` | `as` (`div`, `section`, `article`), `html`, `class` |
|
|
57
60
|
| `Button` | `as` (`button`, `a`), `href`, `variant` (`primary`, `secondary`, `ghost`), `size` (`small`, `medium`, `large`), `disabled`, `type`, `class` |
|
|
58
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` |
|
|
59
65
|
| `Figure` | `as` (`figure`, `div`), `variant` (`default`, `bordered`), `captionAlign` (`start`, `center`), `caption`, `captionSlot` snippet, `class` |
|
|
60
66
|
|
|
61
67
|
### `Typography` `html` prop
|
|
@@ -64,6 +70,56 @@ Each component spreads unknown attributes onto its rendered element and composes
|
|
|
64
70
|
|
|
65
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.
|
|
66
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.
|
|
122
|
+
|
|
67
123
|
## Install
|
|
68
124
|
|
|
69
125
|
```bash
|
package/dist/index.d.ts
CHANGED
|
@@ -2,12 +2,18 @@ export { default as Accordion } from './components/Accordion.svelte';
|
|
|
2
2
|
export { default as Disclosure } from './components/Disclosure.svelte';
|
|
3
3
|
export { default as Field } from './components/Field.svelte';
|
|
4
4
|
export { default as Figure } from './components/Figure.svelte';
|
|
5
|
+
export { default as Input } from './components/Input.svelte';
|
|
5
6
|
export { default as Label } from './components/Label.svelte';
|
|
7
|
+
export { default as Select } from './components/Select.svelte';
|
|
6
8
|
export { default as Table } from './components/Table.svelte';
|
|
9
|
+
export { default as Textarea } from './components/Textarea.svelte';
|
|
7
10
|
export type * from './components/Accordion.svelte';
|
|
8
11
|
export type * from './components/Disclosure.svelte';
|
|
9
12
|
export type * from './components/Field.svelte';
|
|
10
13
|
export type * from './components/Figure.svelte';
|
|
14
|
+
export type * from './components/Input.svelte';
|
|
11
15
|
export type * from './components/Label.svelte';
|
|
16
|
+
export type * from './components/Select.svelte';
|
|
12
17
|
export type * from './components/Table.svelte';
|
|
18
|
+
export type * from './components/Textarea.svelte';
|
|
13
19
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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,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"}
|
|
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
|
@@ -3,5 +3,8 @@ export { default as Accordion } from './components/Accordion.svelte';
|
|
|
3
3
|
export { default as Disclosure } from './components/Disclosure.svelte';
|
|
4
4
|
export { default as Field } from './components/Field.svelte';
|
|
5
5
|
export { default as Figure } from './components/Figure.svelte';
|
|
6
|
+
export { default as Input } from './components/Input.svelte';
|
|
6
7
|
export { default as Label } from './components/Label.svelte';
|
|
8
|
+
export { default as Select } from './components/Select.svelte';
|
|
7
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.
|
|
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)",
|
|
@@ -28,8 +28,11 @@
|
|
|
28
28
|
"./components/Field": "./src/components/Field.svelte",
|
|
29
29
|
"./components/Figure": "./src/components/Figure.svelte",
|
|
30
30
|
"./components/Grid": "./src/components/Grid.svelte",
|
|
31
|
+
"./components/Input": "./src/components/Input.svelte",
|
|
31
32
|
"./components/Label": "./src/components/Label.svelte",
|
|
33
|
+
"./components/Select": "./src/components/Select.svelte",
|
|
32
34
|
"./components/Table": "./src/components/Table.svelte",
|
|
35
|
+
"./components/Textarea": "./src/components/Textarea.svelte",
|
|
33
36
|
"./components/Typography": "./src/components/Typography.svelte"
|
|
34
37
|
},
|
|
35
38
|
"files": [
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLInputAttributes } from 'svelte/elements'
|
|
3
|
+
|
|
4
|
+
export type InputType = 'text' | 'email' | 'password' | 'number' | 'search' | 'url' | 'tel'
|
|
5
|
+
export type InputSize = 'sm' | 'md' | 'lg'
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
type?: InputType
|
|
9
|
+
size?: InputSize
|
|
10
|
+
invalid?: boolean
|
|
11
|
+
class?: string
|
|
12
|
+
value?: string | number
|
|
13
|
+
} & Omit<HTMLInputAttributes, 'size' | 'type' | 'value'>
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
type = 'text',
|
|
17
|
+
size = 'md',
|
|
18
|
+
invalid = false,
|
|
19
|
+
class: className = '',
|
|
20
|
+
value = $bindable(''),
|
|
21
|
+
...attrs
|
|
22
|
+
}: Props = $props()
|
|
23
|
+
|
|
24
|
+
// Field passes its wiring (`id`, `aria-describedby`, `aria-invalid`, `required`, `disabled`) to
|
|
25
|
+
// the `children` snippet, which spreads it onto this control. An explicit attribute wins over the
|
|
26
|
+
// `invalid` prop, so the error state Field reports is the one the element announces and styles.
|
|
27
|
+
const ariaInvalid = $derived(attrs['aria-invalid'] ?? (invalid ? 'true' : undefined))
|
|
28
|
+
// Only the hard `true` state drives the styling; `spelling`/`grammar` are announced but not
|
|
29
|
+
// painted as an error, and an explicit `aria-invalid="false"` always wins over the prop.
|
|
30
|
+
const isInvalid = $derived(ariaInvalid === true || ariaInvalid === 'true')
|
|
31
|
+
|
|
32
|
+
const classes = $derived(
|
|
33
|
+
['rs-input', `rs-input--${size}`, isInvalid ? 'rs-input--invalid' : undefined, className]
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join(' '),
|
|
36
|
+
)
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<input {...attrs} aria-invalid={ariaInvalid} bind:value class={classes} {type} />
|
|
40
|
+
|
|
41
|
+
<style lang="scss">
|
|
42
|
+
@use '@labcat2020/rocketship/styles/mixins' as *;
|
|
43
|
+
|
|
44
|
+
:global {
|
|
45
|
+
.rs-input {
|
|
46
|
+
// Size locals live on the block so a size modifier only reassigns them; `--rs-input-font-size`
|
|
47
|
+
// stays unset here so a consumer can override one control without touching the others.
|
|
48
|
+
--rs-input-font-size-sm: var(--rs-font-size-sm, #{to-rem(14)});
|
|
49
|
+
--rs-input-font-size-md: var(--rs-font-size-md, #{to-rem(16)});
|
|
50
|
+
--rs-input-font-size-lg: var(--rs-font-size-lg, #{to-rem(18)});
|
|
51
|
+
--rs-input-min-height-sm: 34px;
|
|
52
|
+
--rs-input-min-height-md: 40px;
|
|
53
|
+
--rs-input-min-height-lg: 48px;
|
|
54
|
+
--rs-input-padding-inline-sm: var(--rs-space-2, 8px);
|
|
55
|
+
--rs-input-padding-inline-md: var(--rs-space-3, 12px);
|
|
56
|
+
--rs-input-padding-inline-lg: var(--rs-space-4, 16px);
|
|
57
|
+
background: var(--rs-input-bg, var(--rs-color-surface, #ffffff));
|
|
58
|
+
border: var(--rs-input-border-width, var(--rs-border-width-sm, 1px)) solid
|
|
59
|
+
var(--rs-input-border-color, var(--rs-color-border, #c8d2eb));
|
|
60
|
+
border-radius: var(--rs-input-border-radius, var(--rs-radius-sm, 6px));
|
|
61
|
+
box-sizing: border-box;
|
|
62
|
+
color: var(--rs-input-color, var(--rs-color-fg, #131a2f));
|
|
63
|
+
display: block;
|
|
64
|
+
font-family: var(
|
|
65
|
+
--rs-font-family-sans,
|
|
66
|
+
'Inter',
|
|
67
|
+
'Segoe UI',
|
|
68
|
+
Roboto,
|
|
69
|
+
Helvetica,
|
|
70
|
+
Arial,
|
|
71
|
+
sans-serif
|
|
72
|
+
);
|
|
73
|
+
font-size: var(--rs-input-font-size, var(--rs-input-font-size-md));
|
|
74
|
+
line-height: var(--rs-line-height-base, 1.45);
|
|
75
|
+
min-height: var(--rs-input-min-height, var(--rs-input-min-height-md));
|
|
76
|
+
padding-block: var(--rs-input-padding-block, var(--rs-space-2, 8px));
|
|
77
|
+
padding-inline: var(--rs-input-padding-inline, var(--rs-input-padding-inline-md));
|
|
78
|
+
transition:
|
|
79
|
+
background-color var(--rs-transition-fast, 140ms) ease,
|
|
80
|
+
border-color var(--rs-transition-fast, 140ms) ease;
|
|
81
|
+
width: 100%;
|
|
82
|
+
|
|
83
|
+
&::placeholder {
|
|
84
|
+
color: var(--rs-input-placeholder-color, var(--rs-color-muted, #4f5b7d));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
&:focus-visible {
|
|
88
|
+
border-color: var(--rs-input-focus-border-color, var(--rs-color-accent, #2f5bdb));
|
|
89
|
+
outline: 2px solid var(--rs-input-focus-ring-color, var(--rs-color-focus-ring, #296dff));
|
|
90
|
+
outline-offset: 2px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
&:disabled {
|
|
94
|
+
cursor: not-allowed;
|
|
95
|
+
opacity: 0.55;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
&--sm {
|
|
99
|
+
--rs-input-font-size: var(--rs-input-font-size-sm);
|
|
100
|
+
--rs-input-min-height: var(--rs-input-min-height-sm);
|
|
101
|
+
--rs-input-padding-inline: var(--rs-input-padding-inline-sm);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
&--md {
|
|
105
|
+
--rs-input-font-size: var(--rs-input-font-size-md);
|
|
106
|
+
--rs-input-min-height: var(--rs-input-min-height-md);
|
|
107
|
+
--rs-input-padding-inline: var(--rs-input-padding-inline-md);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&--lg {
|
|
111
|
+
--rs-input-font-size: var(--rs-input-font-size-lg);
|
|
112
|
+
--rs-input-min-height: var(--rs-input-min-height-lg);
|
|
113
|
+
--rs-input-padding-inline: var(--rs-input-padding-inline-lg);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The error state owns a coordinated colour set, so the modifier reassigns the locals the base
|
|
117
|
+
// rule already reads instead of restating background, border and colour.
|
|
118
|
+
&--invalid {
|
|
119
|
+
--rs-input-bg: var(--rs-color-danger-bg, #ffeef0);
|
|
120
|
+
--rs-input-border-color: var(--rs-color-danger-border, #f0a8b0);
|
|
121
|
+
--rs-input-color: var(--rs-color-danger-fg, #3d0a08);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@include container-query-up($rs-cq-sm) {
|
|
125
|
+
--rs-input-font-size-sm: var(--rs-font-size-md, #{to-rem(16)});
|
|
126
|
+
--rs-input-font-size-md: var(--rs-font-size-lg, #{to-rem(18)});
|
|
127
|
+
--rs-input-font-size-lg: var(--rs-font-size-xl, #{to-rem(20)});
|
|
128
|
+
--rs-input-min-height-sm: 40px;
|
|
129
|
+
--rs-input-min-height-md: 44px;
|
|
130
|
+
--rs-input-min-height-lg: 52px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@media (prefers-reduced-motion: reduce) {
|
|
134
|
+
transition: none;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
</style>
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
import type { HTMLSelectAttributes } from 'svelte/elements'
|
|
4
|
+
|
|
5
|
+
export type SelectSize = 'sm' | 'md' | 'lg'
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
size?: SelectSize | number
|
|
9
|
+
invalid?: boolean
|
|
10
|
+
placeholder?: string
|
|
11
|
+
class?: string
|
|
12
|
+
value?: string
|
|
13
|
+
children?: Snippet
|
|
14
|
+
} & Omit<HTMLSelectAttributes, 'size' | 'value' | 'placeholder' | 'multiple'>
|
|
15
|
+
|
|
16
|
+
// `size` is a Rocketship variant, but the native attribute of the same name is a visible row
|
|
17
|
+
// count that renders a listbox, so a non-variant value is forwarded to the element untouched.
|
|
18
|
+
const isSelectSize = (candidate: SelectSize | number | undefined): candidate is SelectSize =>
|
|
19
|
+
candidate === 'sm' || candidate === 'md' || candidate === 'lg'
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
size = 'md',
|
|
23
|
+
invalid = false,
|
|
24
|
+
placeholder,
|
|
25
|
+
class: className = '',
|
|
26
|
+
value = $bindable(''),
|
|
27
|
+
children,
|
|
28
|
+
...attrs
|
|
29
|
+
}: Props = $props()
|
|
30
|
+
|
|
31
|
+
const variantSize = $derived(isSelectSize(size) ? size : 'md')
|
|
32
|
+
const nativeSize = $derived(isSelectSize(size) ? undefined : size)
|
|
33
|
+
|
|
34
|
+
// Field passes its wiring (`id`, `aria-describedby`, `aria-invalid`, `required`, `disabled`) to
|
|
35
|
+
// the `children` snippet, which spreads it onto this component and so onto the `<select>`. An
|
|
36
|
+
// explicit attribute wins over the `invalid` prop, so the state the element announces is the one
|
|
37
|
+
// it is styled with.
|
|
38
|
+
const ariaInvalid = $derived(attrs['aria-invalid'] ?? (invalid ? 'true' : undefined))
|
|
39
|
+
// Only the hard `true` state drives the styling; `spelling`/`grammar` are announced but not
|
|
40
|
+
// painted as an error, and an explicit `aria-invalid="false"` always wins over the prop.
|
|
41
|
+
const isInvalid = $derived(ariaInvalid === true || ariaInvalid === 'true')
|
|
42
|
+
|
|
43
|
+
const hasPlaceholder = $derived(Boolean(placeholder?.trim()))
|
|
44
|
+
// A numeric `size` turns the control into a listbox, where a dropdown chevron would be false.
|
|
45
|
+
const showIcon = $derived(nativeSize === undefined)
|
|
46
|
+
|
|
47
|
+
// The placeholder is a disabled, empty-valued option, so a user can never select it back once a
|
|
48
|
+
// real option is chosen. Rendering `selected` keeps static server output on the placeholder; after
|
|
49
|
+
// hydration the `bind:value` binding owns which option is selected.
|
|
50
|
+
const placeholderSelected = $derived(value === '')
|
|
51
|
+
|
|
52
|
+
const classes = $derived(
|
|
53
|
+
[
|
|
54
|
+
'rs-select',
|
|
55
|
+
`rs-select--${variantSize}`,
|
|
56
|
+
isInvalid ? 'rs-select--invalid' : undefined,
|
|
57
|
+
className,
|
|
58
|
+
]
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join(' '),
|
|
61
|
+
)
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<span class={classes}>
|
|
65
|
+
<select
|
|
66
|
+
{...attrs}
|
|
67
|
+
aria-invalid={ariaInvalid}
|
|
68
|
+
bind:value
|
|
69
|
+
class="rs-select__control"
|
|
70
|
+
size={nativeSize}
|
|
71
|
+
>
|
|
72
|
+
{#if hasPlaceholder}
|
|
73
|
+
<option disabled selected={placeholderSelected} value="">{placeholder}</option>
|
|
74
|
+
{/if}
|
|
75
|
+
{@render children?.()}
|
|
76
|
+
</select>
|
|
77
|
+
{#if showIcon}
|
|
78
|
+
<svg
|
|
79
|
+
aria-hidden="true"
|
|
80
|
+
class="rs-select__icon"
|
|
81
|
+
focusable="false"
|
|
82
|
+
height="16"
|
|
83
|
+
viewBox="0 0 16 16"
|
|
84
|
+
width="16"
|
|
85
|
+
>
|
|
86
|
+
<path
|
|
87
|
+
d="M4 6l4 4 4-4"
|
|
88
|
+
fill="none"
|
|
89
|
+
stroke="currentColor"
|
|
90
|
+
stroke-linecap="round"
|
|
91
|
+
stroke-linejoin="round"
|
|
92
|
+
stroke-width="1.5"
|
|
93
|
+
/>
|
|
94
|
+
</svg>
|
|
95
|
+
{/if}
|
|
96
|
+
</span>
|
|
97
|
+
|
|
98
|
+
<style lang="scss">
|
|
99
|
+
@use '@labcat2020/rocketship/styles/mixins' as *;
|
|
100
|
+
|
|
101
|
+
:global {
|
|
102
|
+
.rs-select {
|
|
103
|
+
// Variant-owned locals: the control reads them, so a modifier swaps a coordinated set.
|
|
104
|
+
--rs-select-bg: var(--rs-color-surface, #ffffff);
|
|
105
|
+
--rs-select-border-color: var(--rs-color-border, #c8d2eb);
|
|
106
|
+
--rs-select-color: var(--rs-color-fg, #131a2f);
|
|
107
|
+
--rs-select-focus-ring-color: var(--rs-color-focus-ring, #296dff);
|
|
108
|
+
--rs-select-icon-color: var(--rs-color-muted, #4f5b7d);
|
|
109
|
+
--rs-select-invalid-bg: var(--rs-color-danger-bg, #ffeef0);
|
|
110
|
+
--rs-select-invalid-border-color: var(--rs-color-danger-border, #f0a8b0);
|
|
111
|
+
--rs-select-invalid-focus-ring-color: var(--rs-color-danger-accent, #c5221f);
|
|
112
|
+
// Size defaults live on the block; the per-size hooks stay unset here, so an adopter can
|
|
113
|
+
// override one control through `--rs-select-font-size` without touching the others.
|
|
114
|
+
--rs-select-font-size: var(--rs-select-font-size-md, var(--rs-font-size-md, #{to-rem(16)}));
|
|
115
|
+
--rs-select-min-height: var(--rs-select-min-height-md, 40px);
|
|
116
|
+
--rs-select-padding-inline: var(--rs-select-padding-inline-md, var(--rs-space-3, 12px));
|
|
117
|
+
display: block;
|
|
118
|
+
position: relative;
|
|
119
|
+
|
|
120
|
+
&--sm {
|
|
121
|
+
--rs-select-font-size: var(--rs-select-font-size-sm, var(--rs-font-size-sm, #{to-rem(14)}));
|
|
122
|
+
--rs-select-min-height: var(--rs-select-min-height-sm, 34px);
|
|
123
|
+
--rs-select-padding-inline: var(--rs-select-padding-inline-sm, var(--rs-space-2, 8px));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
&--md {
|
|
127
|
+
--rs-select-font-size: var(--rs-select-font-size-md, var(--rs-font-size-md, #{to-rem(16)}));
|
|
128
|
+
--rs-select-min-height: var(--rs-select-min-height-md, 40px);
|
|
129
|
+
--rs-select-padding-inline: var(--rs-select-padding-inline-md, var(--rs-space-3, 12px));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
&--lg {
|
|
133
|
+
--rs-select-font-size: var(--rs-select-font-size-lg, var(--rs-font-size-lg, #{to-rem(18)}));
|
|
134
|
+
--rs-select-min-height: var(--rs-select-min-height-lg, 48px);
|
|
135
|
+
--rs-select-padding-inline: var(--rs-select-padding-inline-lg, var(--rs-space-4, 16px));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Roomier touch targets once the component's own container is wide enough.
|
|
139
|
+
@include container-query-up($rs-cq-sm) {
|
|
140
|
+
--rs-select-min-height: var(--rs-select-min-height-md, 44px);
|
|
141
|
+
--rs-select-padding-inline: var(--rs-select-padding-inline-md, var(--rs-space-4, 16px));
|
|
142
|
+
|
|
143
|
+
&--sm {
|
|
144
|
+
--rs-select-min-height: var(--rs-select-min-height-sm, 40px);
|
|
145
|
+
--rs-select-padding-inline: var(--rs-select-padding-inline-sm, var(--rs-space-3, 12px));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
&--lg {
|
|
149
|
+
--rs-select-min-height: var(--rs-select-min-height-lg, 52px);
|
|
150
|
+
--rs-select-padding-inline: var(--rs-select-padding-inline-lg, var(--rs-space-5, 20px));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
&__control {
|
|
155
|
+
appearance: none;
|
|
156
|
+
background: var(--rs-select-bg);
|
|
157
|
+
border: var(--rs-select-border-width, var(--rs-border-width-sm, 1px)) solid
|
|
158
|
+
var(--rs-select-border-color);
|
|
159
|
+
border-radius: var(--rs-select-border-radius, var(--rs-radius-sm, 6px));
|
|
160
|
+
box-sizing: border-box;
|
|
161
|
+
color: var(--rs-select-color);
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
display: block;
|
|
164
|
+
font-family: var(
|
|
165
|
+
--rs-font-family-sans,
|
|
166
|
+
'Inter',
|
|
167
|
+
'Segoe UI',
|
|
168
|
+
Roboto,
|
|
169
|
+
Helvetica,
|
|
170
|
+
Arial,
|
|
171
|
+
sans-serif
|
|
172
|
+
);
|
|
173
|
+
font-size: var(--rs-select-font-size);
|
|
174
|
+
line-height: var(--rs-line-height-base, 1.45);
|
|
175
|
+
margin: 0;
|
|
176
|
+
min-height: var(--rs-select-min-height);
|
|
177
|
+
padding-block: var(--rs-select-padding-block, var(--rs-space-2, 8px));
|
|
178
|
+
padding-inline: var(--rs-select-padding-inline);
|
|
179
|
+
padding-inline-end: calc(var(--rs-select-padding-inline) + var(--rs-space-6, 24px));
|
|
180
|
+
transition:
|
|
181
|
+
background-color var(--rs-transition-fast, 140ms) ease,
|
|
182
|
+
border-color var(--rs-transition-fast, 140ms) ease;
|
|
183
|
+
width: 100%;
|
|
184
|
+
|
|
185
|
+
&:hover {
|
|
186
|
+
--rs-select-border-color: var(--rs-color-accent, #2f5bdb);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
&:focus-visible {
|
|
190
|
+
outline: 2px solid var(--rs-select-focus-ring-color);
|
|
191
|
+
outline-offset: 2px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
&:disabled {
|
|
195
|
+
cursor: not-allowed;
|
|
196
|
+
opacity: 0.55;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@media (prefers-reduced-motion: reduce) {
|
|
200
|
+
transition: none;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The error state owns a coordinated colour set, so the modifier reassigns the locals the
|
|
205
|
+
// control already reads instead of restating background, border and colour. It follows the
|
|
206
|
+
// control's `:hover` so its danger border and focus ring outrank the hover accent.
|
|
207
|
+
&--invalid &__control {
|
|
208
|
+
--rs-select-bg: var(--rs-select-invalid-bg);
|
|
209
|
+
--rs-select-border-color: var(--rs-select-invalid-border-color);
|
|
210
|
+
--rs-select-focus-ring-color: var(--rs-select-invalid-focus-ring-color);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
&__icon {
|
|
214
|
+
color: var(--rs-select-icon-color);
|
|
215
|
+
inset-block-start: 50%;
|
|
216
|
+
inset-inline-end: var(--rs-select-padding-inline);
|
|
217
|
+
pointer-events: none;
|
|
218
|
+
position: absolute;
|
|
219
|
+
transform: translateY(-50%);
|
|
220
|
+
transition:
|
|
221
|
+
color var(--rs-transition-fast, 140ms) ease,
|
|
222
|
+
opacity var(--rs-transition-fast, 140ms) ease;
|
|
223
|
+
|
|
224
|
+
@media (prefers-reduced-motion: reduce) {
|
|
225
|
+
transition: none;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The chevron dims with the control, so the pair reads as one disabled unit.
|
|
230
|
+
&__control:disabled + .rs-select__icon {
|
|
231
|
+
opacity: 0.55;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
</style>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLTextareaAttributes } from 'svelte/elements'
|
|
3
|
+
|
|
4
|
+
export type TextareaSize = 'sm' | 'md' | 'lg'
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
size?: TextareaSize
|
|
8
|
+
invalid?: boolean
|
|
9
|
+
class?: string
|
|
10
|
+
value?: string
|
|
11
|
+
} & Omit<HTMLTextareaAttributes, 'value'>
|
|
12
|
+
|
|
13
|
+
let {
|
|
14
|
+
size = 'md',
|
|
15
|
+
invalid = false,
|
|
16
|
+
class: className = '',
|
|
17
|
+
value = $bindable(''),
|
|
18
|
+
...attrs
|
|
19
|
+
}: Props = $props()
|
|
20
|
+
|
|
21
|
+
// Field passes its wiring (`id`, `aria-describedby`, `aria-invalid`, `required`, `disabled`) to
|
|
22
|
+
// the `children` snippet, which spreads it onto this control. An explicit attribute wins over the
|
|
23
|
+
// `invalid` prop, so the error state Field reports is the one the element announces and styles.
|
|
24
|
+
const ariaInvalid = $derived(attrs['aria-invalid'] ?? (invalid ? 'true' : undefined))
|
|
25
|
+
// Only the hard `true` state drives the styling; `spelling`/`grammar` are announced but not
|
|
26
|
+
// painted as an error, and an explicit `aria-invalid="false"` always wins over the prop.
|
|
27
|
+
const isInvalid = $derived(ariaInvalid === true || ariaInvalid === 'true')
|
|
28
|
+
|
|
29
|
+
const classes = $derived(
|
|
30
|
+
[
|
|
31
|
+
'rs-textarea',
|
|
32
|
+
`rs-textarea--${size}`,
|
|
33
|
+
isInvalid ? 'rs-textarea--invalid' : undefined,
|
|
34
|
+
className,
|
|
35
|
+
]
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join(' '),
|
|
38
|
+
)
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<textarea {...attrs} aria-invalid={ariaInvalid} bind:value class={classes}></textarea>
|
|
42
|
+
|
|
43
|
+
<style lang="scss">
|
|
44
|
+
@use '@labcat2020/rocketship/styles/mixins' as *;
|
|
45
|
+
|
|
46
|
+
:global {
|
|
47
|
+
.rs-textarea {
|
|
48
|
+
// Size locals live on the block so a size modifier only reassigns them; `--rs-textarea-font-size`
|
|
49
|
+
// stays unset here so a consumer can override one control without touching the others.
|
|
50
|
+
--rs-textarea-font-size-sm: var(--rs-font-size-sm, #{to-rem(14)});
|
|
51
|
+
--rs-textarea-font-size-md: var(--rs-font-size-md, #{to-rem(16)});
|
|
52
|
+
--rs-textarea-font-size-lg: var(--rs-font-size-lg, #{to-rem(18)});
|
|
53
|
+
--rs-textarea-min-height-sm: 72px;
|
|
54
|
+
--rs-textarea-min-height-md: 96px;
|
|
55
|
+
--rs-textarea-min-height-lg: 128px;
|
|
56
|
+
--rs-textarea-padding-inline-sm: var(--rs-space-2, 8px);
|
|
57
|
+
--rs-textarea-padding-inline-md: var(--rs-space-3, 12px);
|
|
58
|
+
--rs-textarea-padding-inline-lg: var(--rs-space-4, 16px);
|
|
59
|
+
background: var(--rs-textarea-bg, var(--rs-color-surface, #ffffff));
|
|
60
|
+
border: var(--rs-textarea-border-width, var(--rs-border-width-sm, 1px)) solid
|
|
61
|
+
var(--rs-textarea-border-color, var(--rs-color-border, #c8d2eb));
|
|
62
|
+
border-radius: var(--rs-textarea-border-radius, var(--rs-radius-sm, 6px));
|
|
63
|
+
box-sizing: border-box;
|
|
64
|
+
color: var(--rs-textarea-color, var(--rs-color-fg, #131a2f));
|
|
65
|
+
display: block;
|
|
66
|
+
font-family: var(
|
|
67
|
+
--rs-font-family-sans,
|
|
68
|
+
'Inter',
|
|
69
|
+
'Segoe UI',
|
|
70
|
+
Roboto,
|
|
71
|
+
Helvetica,
|
|
72
|
+
Arial,
|
|
73
|
+
sans-serif
|
|
74
|
+
);
|
|
75
|
+
font-size: var(--rs-textarea-font-size, var(--rs-textarea-font-size-md));
|
|
76
|
+
line-height: var(--rs-line-height-base, 1.45);
|
|
77
|
+
min-height: var(--rs-textarea-min-height, var(--rs-textarea-min-height-md));
|
|
78
|
+
padding-block: var(--rs-textarea-padding-block, var(--rs-space-2, 8px));
|
|
79
|
+
padding-inline: var(--rs-textarea-padding-inline, var(--rs-textarea-padding-inline-md));
|
|
80
|
+
resize: var(--rs-textarea-resize, vertical);
|
|
81
|
+
transition:
|
|
82
|
+
background-color var(--rs-transition-fast, 140ms) ease,
|
|
83
|
+
border-color var(--rs-transition-fast, 140ms) ease;
|
|
84
|
+
width: 100%;
|
|
85
|
+
|
|
86
|
+
&::placeholder {
|
|
87
|
+
color: var(--rs-textarea-placeholder-color, var(--rs-color-muted, #4f5b7d));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
&:focus-visible {
|
|
91
|
+
border-color: var(--rs-textarea-focus-border-color, var(--rs-color-accent, #2f5bdb));
|
|
92
|
+
outline: 2px solid var(--rs-textarea-focus-ring-color, var(--rs-color-focus-ring, #296dff));
|
|
93
|
+
outline-offset: 2px;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
&:disabled {
|
|
97
|
+
cursor: not-allowed;
|
|
98
|
+
opacity: 0.55;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
&--sm {
|
|
102
|
+
--rs-textarea-font-size: var(--rs-textarea-font-size-sm);
|
|
103
|
+
--rs-textarea-min-height: var(--rs-textarea-min-height-sm);
|
|
104
|
+
--rs-textarea-padding-inline: var(--rs-textarea-padding-inline-sm);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
&--md {
|
|
108
|
+
--rs-textarea-font-size: var(--rs-textarea-font-size-md);
|
|
109
|
+
--rs-textarea-min-height: var(--rs-textarea-min-height-md);
|
|
110
|
+
--rs-textarea-padding-inline: var(--rs-textarea-padding-inline-md);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
&--lg {
|
|
114
|
+
--rs-textarea-font-size: var(--rs-textarea-font-size-lg);
|
|
115
|
+
--rs-textarea-min-height: var(--rs-textarea-min-height-lg);
|
|
116
|
+
--rs-textarea-padding-inline: var(--rs-textarea-padding-inline-lg);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The error state owns a coordinated colour set, so the modifier reassigns the locals the base
|
|
120
|
+
// rule already reads instead of restating background, border and colour.
|
|
121
|
+
&--invalid {
|
|
122
|
+
--rs-textarea-bg: var(--rs-color-danger-bg, #ffeef0);
|
|
123
|
+
--rs-textarea-border-color: var(--rs-color-danger-border, #f0a8b0);
|
|
124
|
+
--rs-textarea-color: var(--rs-color-danger-fg, #3d0a08);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@include container-query-up($rs-cq-sm) {
|
|
128
|
+
--rs-textarea-font-size-sm: var(--rs-font-size-md, #{to-rem(16)});
|
|
129
|
+
--rs-textarea-font-size-md: var(--rs-font-size-lg, #{to-rem(18)});
|
|
130
|
+
--rs-textarea-font-size-lg: var(--rs-font-size-xl, #{to-rem(20)});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@media (prefers-reduced-motion: reduce) {
|
|
134
|
+
transition: none;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
</style>
|