@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 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
@@ -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.2.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>