@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.
@@ -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>
@@ -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,92 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import type { HTMLLabelAttributes } from 'svelte/elements'
4
+
5
+ export type LabelTag = 'label' | 'span' | 'div'
6
+
7
+ type Props = {
8
+ as?: LabelTag
9
+ required?: boolean
10
+ optional?: boolean
11
+ requiredText?: string
12
+ optionalText?: string
13
+ class?: string
14
+ children?: Snippet
15
+ } & HTMLLabelAttributes
16
+
17
+ let {
18
+ as = 'label',
19
+ required = false,
20
+ optional = false,
21
+ requiredText = '(required)',
22
+ optionalText = '(optional)',
23
+ class: className = '',
24
+ for: htmlFor,
25
+ children,
26
+ ...attrs
27
+ }: Props = $props()
28
+
29
+ const classes = $derived(['rs-label', className].filter(Boolean).join(' '))
30
+ </script>
31
+
32
+ <svelte:element this={as} {...attrs} class={classes} for={as === 'label' ? htmlFor : undefined}>
33
+ {@render children?.()}
34
+ {#if required}
35
+ <span class="rs-label__required" aria-hidden="true">*</span>
36
+ <span class="rs-label__sr-only">{requiredText}</span>
37
+ {:else if optional}
38
+ <span class="rs-label__optional">{optionalText}</span>
39
+ {/if}
40
+ </svelte:element>
41
+
42
+ <style lang="scss">
43
+ @use '@labcat2020/rocketship/styles/mixins' as *;
44
+
45
+ :global {
46
+ .rs-label {
47
+ align-items: baseline;
48
+ color: var(--rs-label-color, var(--rs-color-fg, #131a2f));
49
+ display: flex;
50
+ font-family: var(
51
+ --rs-font-family-sans,
52
+ 'Inter',
53
+ 'Segoe UI',
54
+ Roboto,
55
+ Helvetica,
56
+ Arial,
57
+ sans-serif
58
+ );
59
+ font-size: var(--rs-label-font-size, var(--rs-font-size-sm, #{to-rem(14)}));
60
+ font-weight: var(--rs-label-font-weight, var(--rs-font-weight-medium, 500));
61
+ gap: var(--rs-label-gap, var(--rs-space-1, 4px));
62
+ line-height: var(--rs-label-line-height, var(--rs-line-height-base, 1.45));
63
+
64
+ @include container-query-up($rs-cq-sm) {
65
+ font-size: var(--rs-label-font-size, var(--rs-font-size-md, #{to-rem(16)}));
66
+ }
67
+
68
+ &__required {
69
+ color: var(--rs-label-required-color, var(--rs-color-danger-accent, #c5221f));
70
+ font-weight: var(--rs-font-weight-regular, 400);
71
+ }
72
+
73
+ &__optional {
74
+ color: var(--rs-label-optional-color, var(--rs-color-muted, #4f5b7d));
75
+ font-weight: var(--rs-font-weight-regular, 400);
76
+ }
77
+
78
+ &__sr-only {
79
+ border: 0;
80
+ clip: rect(0 0 0 0);
81
+ clip-path: inset(50%);
82
+ height: 1px;
83
+ margin: -1px;
84
+ overflow: hidden;
85
+ padding: 0;
86
+ position: absolute;
87
+ white-space: nowrap;
88
+ width: 1px;
89
+ }
90
+ }
91
+ }
92
+ </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>