@vit-foundation/ui 0.26.0 → 0.27.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
@@ -25,7 +25,7 @@ Everything exports flat from the root, and again grouped by role:
25
25
  | Entry point | What lives there |
26
26
  | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27
27
  | [`/primitives`](./docs/components/primitives.md) | Button, GhostButton, Link, CardMedia, CardTitle, CopyIntro, DateText, DecorShapes, FilterChips, Pagination, RichText, SearchInput, ShareRow |
28
- | [`/forms`](./docs/forms.md) | Field, FormFeedback, FormErrorFeedback, FormResultSlot, Honeypot, LocaleField, NewsletterIntentField — plus the remote-form seam: structural types, transport constants, field bounds |
28
+ | [`/forms`](./docs/forms.md) | Field, SelectField, TextField, FormFeedback, FormErrorFeedback, FormResultSlot, Honeypot, LocaleField, NewsletterIntentField — plus the remote-form seam: structural types, transport constants, field bounds |
29
29
  | [`/chrome`](./docs/components/chrome.md) | PageShell, Nav, Footer |
30
30
  | [`/content`](./docs/components/content.md) | WeeklieCard, ProjectCard, Timeline, TimelineMilestone, TeamMemberCard, CollaboratorList, JobList, SortSelect, the nine page modules (HomePage … WeeklyPage) — plus the data shapes, the page-copy vocabulary, helpers and the two list rules (createWeeklyList, createUrlFilters) |
31
31
  | [`/community`](./docs/components/community.md) | AuthPageShell, LoginForm, SignupForm, GoogleAuthForm, AccountPanel, NewsletterSignup, CommentSection, ReactionBar, ContactForm |
@@ -10,6 +10,10 @@
10
10
  'M5 7h14M10 7V5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2m3 0v12a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V7m3 4v6m4-6v6',
11
11
  duplicate: 'M9 9h10v12H9zM5 15V3h10',
12
12
  plus: 'M12 5v14M5 12h14',
13
+ /* A month sheet: body, its two hanging tabs, and the rule under the
14
+ weekday row. One subpath list, like `trash` and `logout`. */
15
+ calendar:
16
+ 'M5 6h14a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zM4 10.5h16M8.5 3.5V6M15.5 3.5V6',
13
17
  'chevron-right': 'M9 5l7 7-7 7',
14
18
  'chevron-left': 'M15 5l-7 7 7 7',
15
19
  close: 'M6 6l12 12M18 6L6 18',
@@ -8,6 +8,7 @@ declare const PATHS: {
8
8
  readonly trash: "M5 7h14M10 7V5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2m3 0v12a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V7m3 4v6m4-6v6";
9
9
  readonly duplicate: "M9 9h10v12H9zM5 15V3h10";
10
10
  readonly plus: "M12 5v14M5 12h14";
11
+ readonly calendar: "M5 6h14a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zM4 10.5h16M8.5 3.5V6M15.5 3.5V6";
11
12
  readonly 'chevron-right': "M9 5l7 7-7 7";
12
13
  readonly 'chevron-left': "M15 5l-7 7 7 7";
13
14
  readonly close: "M6 6l12 12M18 6L6 18";
@@ -0,0 +1,207 @@
1
+ <script lang="ts" module>
2
+ /** One option row. `value` is what the control carries: a string, always. */
3
+ export interface SelectOption {
4
+ value: string;
5
+ label: string;
6
+ disabled?: boolean;
7
+ }
8
+ </script>
9
+
10
+ <script lang="ts">
11
+ import type { Snippet } from 'svelte';
12
+ import type { HTMLSelectAttributes } from 'svelte/elements';
13
+
14
+ /**
15
+ * A labelled `<select>` — `TextField`'s sibling for a choice out of a list.
16
+ *
17
+ * The package had none, so eleven call sites across the two hosts each grew
18
+ * their own. That produced four spellings of ONE navy pill, a second box
19
+ * skin whose corner radius was hard-coded past `--radius` because the admin
20
+ * theme sets that token to `999px`, and two incompatible ways of reporting
21
+ * the same refusal: the user editor wires `aria-invalid` and
22
+ * `aria-describedby`, while the record field one module over announced its
23
+ * rejection with a bare `role="alert()"` and left the control unmarked. A
24
+ * control that cannot be rendered unwired is the point here, the way
25
+ * `Field`'s snippet is.
26
+ *
27
+ * The value is a STRING and stays one. Both hosts had already converged on
28
+ * that independently — a `<select>` binds a string, and a year, a row id or
29
+ * a boolean flag becomes itself again in the one adapter that reads it — so
30
+ * a value type here would fork the component per caller.
31
+ */
32
+ interface Props {
33
+ label: string;
34
+ id: string;
35
+ /**
36
+ * How it presents itself. Three, which is what the call sites were:
37
+ *
38
+ * - `field` — labelled box, the default.
39
+ * - `pill` — filled navy, for a toolbar beside a heading.
40
+ * - `bare` — box, label to the screen reader only, for inline chrome
41
+ * that already reads as labelled by what surrounds it.
42
+ */
43
+ variant?: 'field' | 'pill' | 'bare';
44
+ /** Label beside the control rather than above it — a toolbar row. */
45
+ inline?: boolean;
46
+ value?: string;
47
+ required?: boolean;
48
+ disabled?: boolean;
49
+ /**
50
+ * The leading option. `disabled` makes it a placeholder the reader must
51
+ * choose past; without it the empty value is selectable and MEANS
52
+ * something — "No manager", "every table".
53
+ */
54
+ placeholder?: { label: string; disabled?: boolean };
55
+ /** Options whose labels are data. Use `children` when a label is computed. */
56
+ options?: readonly SelectOption[];
57
+ children?: Snippet;
58
+ error?: string | null;
59
+ /** Standing help. Described alongside an error rather than replaced by it. */
60
+ hint?: string | null;
61
+ /**
62
+ * Beside the binding, not instead of it: the callers that need one are
63
+ * coercing the string (a year to a number) or acting on the choice (a
64
+ * template applied, a draft committed).
65
+ */
66
+ onchange?: (value: string) => void;
67
+ /**
68
+ * A remote field's `.as('select')` attachment, spread instead of bound —
69
+ * the same branch `TextField` takes, so a remote form does not route
70
+ * around this module and re-declare the skin.
71
+ */
72
+ attributes?: HTMLSelectAttributes;
73
+ /** Native form submit: the browser posts under this name. */
74
+ name?: string;
75
+ }
76
+
77
+ let {
78
+ label,
79
+ id,
80
+ variant = 'field',
81
+ inline = false,
82
+ value = $bindable(''),
83
+ required = false,
84
+ disabled = false,
85
+ placeholder,
86
+ options,
87
+ children,
88
+ error = null,
89
+ hint = null,
90
+ onchange,
91
+ attributes,
92
+ name
93
+ }: Props = $props();
94
+
95
+ /** Both ids when both are showing: an error does not hide standing help. */
96
+ const describedBy = $derived(
97
+ [error ? `${id}-error` : null, hint ? `${id}-hint` : null].filter(Boolean).join(' ') ||
98
+ undefined
99
+ );
100
+
101
+ const shared = $derived({
102
+ id,
103
+ name,
104
+ required,
105
+ disabled,
106
+ 'aria-invalid': error ? true : undefined,
107
+ 'aria-describedby': describedBy
108
+ });
109
+ </script>
110
+
111
+ {#snippet body()}
112
+ {#if placeholder}
113
+ <option value="" disabled={placeholder.disabled}>{placeholder.label}</option>
114
+ {/if}
115
+ {#each options ?? [] as option (option.value)}
116
+ <option value={option.value} disabled={option.disabled}>{option.label}</option>
117
+ {/each}
118
+ {@render children?.()}
119
+ {/snippet}
120
+
121
+ <div class="select-field" class:inline class:pill={variant === 'pill'}>
122
+ <label for={id} class:visually-hidden={variant === 'bare'}>{label}</label>
123
+ {#if attributes}
124
+ <!-- The form owns the value and its name. -->
125
+ <select {...shared} {...attributes}>{@render body()}</select>
126
+ {:else}
127
+ <select {...shared} bind:value onchange={(event) => onchange?.(event.currentTarget.value)}>
128
+ {@render body()}
129
+ </select>
130
+ {/if}
131
+ {#if hint}
132
+ <p class="hint" id="{id}-hint">{hint}</p>
133
+ {/if}
134
+ {#if error}
135
+ <p class="error" id="{id}-error">{error}</p>
136
+ {/if}
137
+ </div>
138
+
139
+ <style>
140
+ .select-field {
141
+ display: flex;
142
+ flex-direction: column;
143
+ gap: var(--space-1);
144
+ }
145
+
146
+ .select-field.inline {
147
+ flex-direction: row;
148
+ align-items: center;
149
+ gap: var(--space-2);
150
+ }
151
+
152
+ label {
153
+ font-size: var(--text-sm);
154
+ font-weight: 500;
155
+ }
156
+
157
+ select {
158
+ border: 1px solid var(--color-axis);
159
+ /* Not `--radius`: the admin theme sets that to 999px for its pill
160
+ buttons, which is why both of that host's boxed selects hard-coded a
161
+ corner past it. `--radius-control` is the input corner in both. */
162
+ border-radius: var(--radius-control);
163
+ background: var(--color-surface);
164
+ padding: 0.35rem 0.6rem;
165
+ font-family: var(--font-sans);
166
+ font-size: var(--text-base);
167
+ color: var(--color-ink);
168
+ }
169
+
170
+ select:focus-visible {
171
+ outline: none;
172
+ border-color: var(--color-navy);
173
+ }
174
+
175
+ select:disabled {
176
+ opacity: 0.5;
177
+ }
178
+
179
+ /* The filled toolbar control: a heading's neighbour, not a form field. It
180
+ takes its content's width — a form field stretches, a pill does not. */
181
+ .pill select {
182
+ align-self: flex-start;
183
+ border-color: var(--color-navy);
184
+ border-radius: 999px;
185
+ background: var(--color-navy);
186
+ color: var(--color-surface);
187
+ padding: 0.3rem 0.8rem;
188
+ font-size: var(--text-sm);
189
+ }
190
+
191
+ .pill select:focus-visible {
192
+ outline: 2px solid var(--color-navy);
193
+ outline-offset: 2px;
194
+ }
195
+
196
+ .hint {
197
+ color: var(--color-ink-muted);
198
+ font-size: 0.8rem;
199
+ margin: 0;
200
+ }
201
+
202
+ .error {
203
+ color: var(--series-8);
204
+ font-size: 0.8rem;
205
+ margin: 0;
206
+ }
207
+ </style>
@@ -0,0 +1,76 @@
1
+ /** One option row. `value` is what the control carries: a string, always. */
2
+ export interface SelectOption {
3
+ value: string;
4
+ label: string;
5
+ disabled?: boolean;
6
+ }
7
+ import type { Snippet } from 'svelte';
8
+ import type { HTMLSelectAttributes } from 'svelte/elements';
9
+ /**
10
+ * A labelled `<select>` — `TextField`'s sibling for a choice out of a list.
11
+ *
12
+ * The package had none, so eleven call sites across the two hosts each grew
13
+ * their own. That produced four spellings of ONE navy pill, a second box
14
+ * skin whose corner radius was hard-coded past `--radius` because the admin
15
+ * theme sets that token to `999px`, and two incompatible ways of reporting
16
+ * the same refusal: the user editor wires `aria-invalid` and
17
+ * `aria-describedby`, while the record field one module over announced its
18
+ * rejection with a bare `role="alert()"` and left the control unmarked. A
19
+ * control that cannot be rendered unwired is the point here, the way
20
+ * `Field`'s snippet is.
21
+ *
22
+ * The value is a STRING and stays one. Both hosts had already converged on
23
+ * that independently — a `<select>` binds a string, and a year, a row id or
24
+ * a boolean flag becomes itself again in the one adapter that reads it — so
25
+ * a value type here would fork the component per caller.
26
+ */
27
+ interface Props {
28
+ label: string;
29
+ id: string;
30
+ /**
31
+ * How it presents itself. Three, which is what the call sites were:
32
+ *
33
+ * - `field` — labelled box, the default.
34
+ * - `pill` — filled navy, for a toolbar beside a heading.
35
+ * - `bare` — box, label to the screen reader only, for inline chrome
36
+ * that already reads as labelled by what surrounds it.
37
+ */
38
+ variant?: 'field' | 'pill' | 'bare';
39
+ /** Label beside the control rather than above it — a toolbar row. */
40
+ inline?: boolean;
41
+ value?: string;
42
+ required?: boolean;
43
+ disabled?: boolean;
44
+ /**
45
+ * The leading option. `disabled` makes it a placeholder the reader must
46
+ * choose past; without it the empty value is selectable and MEANS
47
+ * something — "No manager", "every table".
48
+ */
49
+ placeholder?: {
50
+ label: string;
51
+ disabled?: boolean;
52
+ };
53
+ /** Options whose labels are data. Use `children` when a label is computed. */
54
+ options?: readonly SelectOption[];
55
+ children?: Snippet;
56
+ error?: string | null;
57
+ /** Standing help. Described alongside an error rather than replaced by it. */
58
+ hint?: string | null;
59
+ /**
60
+ * Beside the binding, not instead of it: the callers that need one are
61
+ * coercing the string (a year to a number) or acting on the choice (a
62
+ * template applied, a draft committed).
63
+ */
64
+ onchange?: (value: string) => void;
65
+ /**
66
+ * A remote field's `.as('select')` attachment, spread instead of bound —
67
+ * the same branch `TextField` takes, so a remote form does not route
68
+ * around this module and re-declare the skin.
69
+ */
70
+ attributes?: HTMLSelectAttributes;
71
+ /** Native form submit: the browser posts under this name. */
72
+ name?: string;
73
+ }
74
+ declare const SelectField: import("svelte").Component<Props, {}, "value">;
75
+ type SelectField = ReturnType<typeof SelectField>;
76
+ export default SelectField;
@@ -5,6 +5,7 @@ export { default as FormResultSlot } from '../components/ui/FormResultSlot.svelt
5
5
  export { default as Honeypot } from '../components/ui/Honeypot.svelte';
6
6
  export { default as LocaleField } from '../components/ui/LocaleField.svelte';
7
7
  export { default as NewsletterIntentField } from '../components/ui/NewsletterIntentField.svelte';
8
+ export { default as SelectField, type SelectOption } from '../components/ui/SelectField.svelte';
8
9
  export { default as TextField } from '../components/ui/TextField.svelte';
9
10
  export { COMMENT_BODY, CONTACT_MESSAGE, CONTACT_NAME, DISPLAY_NAME, EMAIL, LOGIN_PASSWORD, PASSWORD } from './constraints.js';
10
11
  export { hasNewsletterIntent, HONEYPOT_FIELD, isNewsletterIntent, NEWSLETTER_INTENT_PARAM, NEWSLETTER_INTENT_VALUE, withNewsletterIntent } from './transport.js';
@@ -5,6 +5,7 @@ export { default as FormResultSlot } from '../components/ui/FormResultSlot.svelt
5
5
  export { default as Honeypot } from '../components/ui/Honeypot.svelte';
6
6
  export { default as LocaleField } from '../components/ui/LocaleField.svelte';
7
7
  export { default as NewsletterIntentField } from '../components/ui/NewsletterIntentField.svelte';
8
+ export { default as SelectField } from '../components/ui/SelectField.svelte';
8
9
  export { default as TextField } from '../components/ui/TextField.svelte';
9
10
  export { COMMENT_BODY, CONTACT_MESSAGE, CONTACT_NAME, DISPLAY_NAME, EMAIL, LOGIN_PASSWORD, PASSWORD } from './constraints.js';
10
11
  export { hasNewsletterIntent, HONEYPOT_FIELD, isNewsletterIntent, NEWSLETTER_INTENT_PARAM, NEWSLETTER_INTENT_VALUE, withNewsletterIntent } from './transport.js';
@@ -40,7 +40,7 @@
40
40
  * Sizing (padding, widths) stays with the component that adds the class. */
41
41
  .control {
42
42
  border: 1px solid var(--color-axis);
43
- border-radius: var(--radius);
43
+ border-radius: var(--radius-control);
44
44
  background: var(--color-surface);
45
45
  }
46
46
 
@@ -50,6 +50,10 @@
50
50
 
51
51
  /* Shape & layout */
52
52
  --radius: 8px;
53
+ /* The corner of an input, a select, a textarea — the `.control` skin.
54
+ Separate from `--radius` because a theme that makes its buttons pills
55
+ (the admin app sets `--radius: 999px`) still wants a boxed field. */
56
+ --radius-control: 8px;
53
57
  --radius-lg: 16px;
54
58
  --content-max: 1200px;
55
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vit-foundation/ui",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",