@mfp-design-system/select 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Melissa Pula
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @mfp-design-system/select
2
+
3
+ A Lit-based `<mfp-select>` web component that wraps a native `<select>` element.
4
+
5
+ ## Why a native wrapper?
6
+
7
+ Native `<select>` gets you full keyboard a11y, screen reader support, and mobile-native pickers (iOS wheel, Android sheet) for free. Only the visual chrome (border, focus ring, chevron) is custom. A full custom-combobox implementation is on the roadmap if/when you need things like search-as-you-type or custom option rendering.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install @mfp-design-system/select @mfp-design-system/tokens
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import '@mfp-design-system/select';
19
+ import '@mfp-design-system/tokens/css';
20
+ ```
21
+
22
+ ```html
23
+ <mfp-select label="Color" placeholder="Pick one…">
24
+ <option value="red">Red</option>
25
+ <option value="green">Green</option>
26
+ <option value="blue">Blue</option>
27
+ </mfp-select>
28
+
29
+ <mfp-select label="Fruit" placeholder="Choose…">
30
+ <optgroup label="Citrus">
31
+ <option value="orange">Orange</option>
32
+ <option value="lemon">Lemon</option>
33
+ </optgroup>
34
+ <optgroup label="Berries">
35
+ <option value="strawberry">Strawberry</option>
36
+ </optgroup>
37
+ </mfp-select>
38
+ ```
39
+
40
+ Children projected into the default slot (`<option>` and `<optgroup>` elements) are forwarded into the real `<select>` inside shadow DOM. The slot itself is hidden.
41
+
42
+ ## API
43
+
44
+ | Attribute | Type | Default | Description |
45
+ | ------------- | ---------------------- | ------- | ------------------------------------------------------------ |
46
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Sizing |
47
+ | `value` | `string` | `''` | Current value (controlled) |
48
+ | `name` | `string` | `''` | Native form `name` |
49
+ | `label` | `string` | `''` | Visible label (auto-wired with `for`/`id`) |
50
+ | `placeholder` | `string` | `''` | Renders a disabled, hidden first option as the initial value |
51
+ | `hint` | `string` | `''` | Helper text shown below the select |
52
+ | `error` | `string` | `''` | Error message — also triggers invalid styles |
53
+ | `disabled` | `boolean` | `false` | Disables the select |
54
+ | `required` | `boolean` | `false` | Marks as required (adds \* to label) |
55
+
56
+ ### Events
57
+
58
+ - `change` — fires when the value changes. `event.detail.value` carries the new value.
59
+
60
+ ### Shadow parts
61
+
62
+ For custom styling: `label`, `control`, `select`, `chevron`, `hint`, `error`.
63
+
64
+ ```css
65
+ mfp-select::part(chevron) {
66
+ color: var(--color-status-info-solid);
67
+ }
68
+ ```
69
+
70
+ ## Framework notes
71
+
72
+ Same as the rest of the suite — Vue/Nuxt need `isCustomElement`, Angular needs `CUSTOM_ELEMENTS_SCHEMA`.
73
+
74
+ ## Known limitations
75
+
76
+ - Single-select only. Multi-select via the native `<select multiple>` UI is uglier than most teams want — multi-select is on the roadmap as a separate component (`<mfp-multi-select>`) with a checkbox-list pattern.
77
+ - Form submission via native form: the inner `<select>` is in shadow DOM, so it does not participate in native form submission yet. Listen for `change` events; full `ElementInternals` form-association is on the roadmap for all form components.
@@ -0,0 +1,3 @@
1
+ export { MfpSelect } from './select.js';
2
+ export type { SelectSize } from './select.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { MfpSelect } from './select.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,39 @@
1
+ import { LitElement } from 'lit';
2
+ export type SelectSize = 'sm' | 'md' | 'lg';
3
+ /**
4
+ * `<mfp-select>` wraps a native `<select>` element. Children projected into
5
+ * the default slot are forwarded into the real select — usage:
6
+ *
7
+ * <mfp-select label="Color">
8
+ * <option value="red">Red</option>
9
+ * <option value="green">Green</option>
10
+ * </mfp-select>
11
+ *
12
+ * Using the native control means full keyboard a11y, screen reader support,
13
+ * and mobile-native pickers come for free. The visual chrome (border, focus
14
+ * ring, chevron) is styled via shadow CSS using design tokens.
15
+ */
16
+ export declare class MfpSelect extends LitElement {
17
+ static styles: import("lit").CSSResult;
18
+ size: SelectSize;
19
+ value: string;
20
+ name: string;
21
+ label: string;
22
+ placeholder: string;
23
+ hint: string;
24
+ error: string;
25
+ disabled: boolean;
26
+ required: boolean;
27
+ private _id;
28
+ private _selectEl;
29
+ private _onChange;
30
+ /** Move slotted <option> elements into the real <select> when they change. */
31
+ private _onSlotChange;
32
+ render(): import("lit").TemplateResult<1>;
33
+ }
34
+ declare global {
35
+ interface HTMLElementTagNameMap {
36
+ 'mfp-select': MfpSelect;
37
+ }
38
+ }
39
+ //# sourceMappingURL=select.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"select.d.ts","sourceRoot":"","sources":["../src/select.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAsB,MAAM,KAAK,CAAC;AAGrD,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAI5C;;;;;;;;;;;;GAYG;AACH,qBACa,SAAU,SAAQ,UAAU;IACrC,OAAgB,MAAM,0BA2HpB;IAGF,IAAI,EAAE,UAAU,CAAQ;IAGxB,KAAK,SAAM;IAGX,IAAI,SAAM;IAGV,KAAK,SAAM;IAGX,WAAW,SAAM;IAGjB,IAAI,SAAM;IAGV,KAAK,SAAM;IAGX,QAAQ,UAAS;IAGjB,QAAQ,UAAS;IAEjB,OAAO,CAAC,GAAG,CAAqC;IAGhD,OAAO,CAAC,SAAS,CAAqB;IAEtC,OAAO,CAAC,SAAS,CAUf;IAEF,8EAA8E;IAC9E,OAAO,CAAC,aAAa,CAyBnB;IAEO,MAAM;CA4DlB;AAED,OAAO,CAAC,MAAM,CAAC;IACX,UAAU,qBAAqB;QAC3B,YAAY,EAAE,SAAS,CAAC;KAC3B;CACJ"}
package/dist/select.js ADDED
@@ -0,0 +1,287 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { LitElement, html, css, nothing } from 'lit';
8
+ import { customElement, property, query } from 'lit/decorators.js';
9
+ let selectIdCounter = 0;
10
+ /**
11
+ * `<mfp-select>` wraps a native `<select>` element. Children projected into
12
+ * the default slot are forwarded into the real select — usage:
13
+ *
14
+ * <mfp-select label="Color">
15
+ * <option value="red">Red</option>
16
+ * <option value="green">Green</option>
17
+ * </mfp-select>
18
+ *
19
+ * Using the native control means full keyboard a11y, screen reader support,
20
+ * and mobile-native pickers come for free. The visual chrome (border, focus
21
+ * ring, chevron) is styled via shadow CSS using design tokens.
22
+ */
23
+ let MfpSelect = class MfpSelect extends LitElement {
24
+ constructor() {
25
+ super(...arguments);
26
+ this.size = 'md';
27
+ this.value = '';
28
+ this.name = '';
29
+ this.label = '';
30
+ this.placeholder = '';
31
+ this.hint = '';
32
+ this.error = '';
33
+ this.disabled = false;
34
+ this.required = false;
35
+ this._id = `mfp-select-${++selectIdCounter}`;
36
+ this._onChange = (e) => {
37
+ const select = e.target;
38
+ this.value = select.value;
39
+ this.dispatchEvent(new CustomEvent('change', {
40
+ bubbles: true,
41
+ composed: true,
42
+ detail: { value: select.value },
43
+ }));
44
+ };
45
+ /** Move slotted <option> elements into the real <select> when they change. */
46
+ this._onSlotChange = (e) => {
47
+ const slot = e.target;
48
+ const select = this._selectEl;
49
+ if (!select)
50
+ return;
51
+ const currentValue = this.value;
52
+ const optionLikeNodes = slot
53
+ .assignedNodes({ flatten: true })
54
+ .filter((n) => n.nodeType === Node.ELEMENT_NODE &&
55
+ (n.tagName === 'OPTION' ||
56
+ n.tagName === 'OPTGROUP'));
57
+ // Replace existing real options (other than the placeholder, which lives in the template)
58
+ const placeholder = select.querySelector('option[data-mfp-placeholder]');
59
+ select.textContent = '';
60
+ if (placeholder)
61
+ select.appendChild(placeholder);
62
+ for (const node of optionLikeNodes) {
63
+ select.appendChild(node.cloneNode(true));
64
+ }
65
+ // Restore the value (reflect attribute → DOM value)
66
+ select.value = currentValue;
67
+ };
68
+ }
69
+ static { this.styles = css `
70
+ :host {
71
+ display: block;
72
+ font-family: var(--font-family-sans, system-ui, -apple-system, sans-serif);
73
+ color: var(--color-text-default, #111827);
74
+ }
75
+
76
+ :host([disabled]) {
77
+ opacity: 0.6;
78
+ }
79
+
80
+ label {
81
+ display: block;
82
+ font-size: var(--font-size-sm, 14px);
83
+ font-weight: var(--font-weight-medium, 500);
84
+ line-height: var(--font-line-height-tight, 1.2);
85
+ margin-bottom: var(--size-spacing-2, 8px);
86
+ }
87
+
88
+ .required {
89
+ color: var(--color-status-error-solid, #dc2626);
90
+ margin-left: var(--size-spacing-1, 4px);
91
+ }
92
+
93
+ .control {
94
+ position: relative;
95
+ display: flex;
96
+ align-items: center;
97
+ background: var(--color-neutral-0, #ffffff);
98
+ border: 1px solid var(--color-border-default, #e5e7eb);
99
+ border-radius: var(--size-radius-md, 8px);
100
+ transition:
101
+ border-color var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
102
+ box-shadow var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease);
103
+ }
104
+
105
+ .control:focus-within {
106
+ border-color: var(--color-status-info-solid, #2563eb);
107
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
108
+ }
109
+
110
+ .control.invalid {
111
+ border-color: var(--color-status-error-solid, #dc2626);
112
+ }
113
+
114
+ .control.invalid:focus-within {
115
+ box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
116
+ }
117
+
118
+ select {
119
+ flex: 1 1 auto;
120
+ min-width: 0;
121
+ appearance: none;
122
+ -webkit-appearance: none;
123
+ background: transparent;
124
+ border: none;
125
+ outline: none;
126
+ font: inherit;
127
+ color: inherit;
128
+ cursor: pointer;
129
+ padding: var(--size-spacing-2, 8px) var(--size-spacing-9, 36px)
130
+ var(--size-spacing-2, 8px) var(--size-spacing-3, 12px);
131
+ }
132
+
133
+ select:disabled {
134
+ cursor: not-allowed;
135
+ }
136
+
137
+ .chevron {
138
+ position: absolute;
139
+ right: var(--size-spacing-3, 12px);
140
+ top: 50%;
141
+ transform: translateY(-50%);
142
+ width: 1em;
143
+ height: 1em;
144
+ color: var(--color-text-muted, #6b7280);
145
+ pointer-events: none;
146
+ }
147
+
148
+ /* Sizes — fall back to medium when no [size] attribute is set */
149
+ :host(:not([size])) select,
150
+ :host([size='md']) select {
151
+ font-size: var(--font-size-base, 16px);
152
+ min-height: 40px;
153
+ }
154
+ :host([size='sm']) select {
155
+ font-size: var(--font-size-sm, 14px);
156
+ min-height: 32px;
157
+ padding: var(--size-spacing-1, 4px) var(--size-spacing-9, 36px)
158
+ var(--size-spacing-1, 4px) var(--size-spacing-3, 12px);
159
+ }
160
+ :host([size='lg']) select {
161
+ font-size: var(--font-size-lg, 18px);
162
+ min-height: 48px;
163
+ padding: var(--size-spacing-3, 12px) var(--size-spacing-10, 40px)
164
+ var(--size-spacing-3, 12px) var(--size-spacing-4, 16px);
165
+ }
166
+
167
+ .hint,
168
+ .error {
169
+ margin: var(--size-spacing-2, 8px) 0 0;
170
+ font-size: var(--font-size-sm, 14px);
171
+ line-height: var(--font-line-height-tight, 1.2);
172
+ }
173
+
174
+ .hint {
175
+ color: var(--color-text-muted, #6b7280);
176
+ }
177
+
178
+ .error {
179
+ color: var(--color-status-error-solid, #dc2626);
180
+ }
181
+
182
+ /* Hide the slot — its children get moved into the native select */
183
+ .options-source {
184
+ display: none;
185
+ }
186
+
187
+ @media (prefers-reduced-motion: reduce) {
188
+ .control {
189
+ transition: none;
190
+ }
191
+ }
192
+ `; }
193
+ render() {
194
+ const invalid = this.error.length > 0;
195
+ const inputId = this._id;
196
+ const hintId = `${inputId}-hint`;
197
+ const errorId = `${inputId}-error`;
198
+ const describedBy = invalid ? errorId : this.hint ? hintId : undefined;
199
+ return html `
200
+ ${this.label
201
+ ? html `<label part="label" for=${inputId}>
202
+ ${this.label}
203
+ ${this.required
204
+ ? html `<span class="required" aria-hidden="true">*</span>`
205
+ : nothing}
206
+ </label>`
207
+ : nothing}
208
+ <div part="control" class="control ${invalid ? 'invalid' : ''}">
209
+ <select
210
+ id=${inputId}
211
+ part="select"
212
+ .value=${this.value}
213
+ name=${this.name}
214
+ ?disabled=${this.disabled}
215
+ ?required=${this.required}
216
+ aria-invalid=${invalid ? 'true' : 'false'}
217
+ aria-describedby=${describedBy ?? nothing}
218
+ @change=${this._onChange}
219
+ >
220
+ ${this.placeholder
221
+ ? html `<option value="" disabled selected hidden data-mfp-placeholder>
222
+ ${this.placeholder}
223
+ </option>`
224
+ : nothing}
225
+ </select>
226
+ <svg
227
+ class="chevron"
228
+ part="chevron"
229
+ viewBox="0 0 16 16"
230
+ fill="none"
231
+ aria-hidden="true"
232
+ >
233
+ <path
234
+ d="M4 6l4 4 4-4"
235
+ stroke="currentColor"
236
+ stroke-width="1.5"
237
+ stroke-linecap="round"
238
+ stroke-linejoin="round"
239
+ />
240
+ </svg>
241
+ </div>
242
+ <div class="options-source">
243
+ <slot @slotchange=${this._onSlotChange}></slot>
244
+ </div>
245
+ ${invalid
246
+ ? html `<p part="error" id=${errorId} class="error" role="alert">${this.error}</p>`
247
+ : this.hint
248
+ ? html `<p part="hint" id=${hintId} class="hint">${this.hint}</p>`
249
+ : nothing}
250
+ `;
251
+ }
252
+ };
253
+ __decorate([
254
+ property({ reflect: true })
255
+ ], MfpSelect.prototype, "size", void 0);
256
+ __decorate([
257
+ property()
258
+ ], MfpSelect.prototype, "value", void 0);
259
+ __decorate([
260
+ property()
261
+ ], MfpSelect.prototype, "name", void 0);
262
+ __decorate([
263
+ property()
264
+ ], MfpSelect.prototype, "label", void 0);
265
+ __decorate([
266
+ property()
267
+ ], MfpSelect.prototype, "placeholder", void 0);
268
+ __decorate([
269
+ property()
270
+ ], MfpSelect.prototype, "hint", void 0);
271
+ __decorate([
272
+ property()
273
+ ], MfpSelect.prototype, "error", void 0);
274
+ __decorate([
275
+ property({ type: Boolean, reflect: true })
276
+ ], MfpSelect.prototype, "disabled", void 0);
277
+ __decorate([
278
+ property({ type: Boolean, reflect: true })
279
+ ], MfpSelect.prototype, "required", void 0);
280
+ __decorate([
281
+ query('select')
282
+ ], MfpSelect.prototype, "_selectEl", void 0);
283
+ MfpSelect = __decorate([
284
+ customElement('mfp-select')
285
+ ], MfpSelect);
286
+ export { MfpSelect };
287
+ //# sourceMappingURL=select.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"select.js","sourceRoot":"","sources":["../src/select.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@mfp-design-system/select",
3
+ "version": "0.1.0",
4
+ "description": "Select dropdown web component for the mfp-design-system, built with Lit.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": true,
8
+ "files": [
9
+ "dist",
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "main": "./dist/index.js",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "dependencies": {
24
+ "lit": "^3.2.1"
25
+ },
26
+ "peerDependencies": {
27
+ "@mfp-design-system/tokens": "^0.2.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "@mfp-design-system/tokens": {
31
+ "optional": true
32
+ }
33
+ },
34
+ "devDependencies": {
35
+ "rimraf": "^6.0.1",
36
+ "typescript": "^5.6.3",
37
+ "@mfp-design-system/tokens": "0.2.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "dev": "tsc -p tsconfig.build.json --watch",
45
+ "clean": "rimraf dist",
46
+ "lint": "eslint . --max-warnings=0",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "echo \"no tests yet\""
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { MfpSelect } from './select.js';
2
+ export type { SelectSize } from './select.js';
@@ -0,0 +1,124 @@
1
+ import type { Meta, StoryObj } from '@storybook/web-components';
2
+ import { html } from 'lit';
3
+ import './select.js';
4
+ import type { SelectSize } from './select.js';
5
+
6
+ interface Args {
7
+ label: string;
8
+ size: SelectSize;
9
+ placeholder: string;
10
+ value: string;
11
+ hint: string;
12
+ error: string;
13
+ disabled: boolean;
14
+ required: boolean;
15
+ }
16
+
17
+ const meta: Meta<Args> = {
18
+ title: 'Components/Select',
19
+ component: 'mfp-select',
20
+ tags: ['autodocs'],
21
+ argTypes: {
22
+ label: { control: 'text' },
23
+ size: { control: 'select', options: ['sm', 'md', 'lg'] },
24
+ placeholder: { control: 'text' },
25
+ value: { control: 'text' },
26
+ hint: { control: 'text' },
27
+ error: { control: 'text' },
28
+ disabled: { control: 'boolean' },
29
+ required: { control: 'boolean' },
30
+ },
31
+ args: {
32
+ label: 'Favorite color',
33
+ size: 'md',
34
+ placeholder: 'Pick one…',
35
+ value: '',
36
+ hint: '',
37
+ error: '',
38
+ disabled: false,
39
+ required: false,
40
+ },
41
+ render: (args) => html`
42
+ <mfp-select
43
+ label=${args.label}
44
+ size=${args.size}
45
+ placeholder=${args.placeholder}
46
+ .value=${args.value}
47
+ hint=${args.hint}
48
+ error=${args.error}
49
+ ?disabled=${args.disabled}
50
+ ?required=${args.required}
51
+ style="max-width: 320px;"
52
+ >
53
+ <option value="red">Red</option>
54
+ <option value="orange">Orange</option>
55
+ <option value="yellow">Yellow</option>
56
+ <option value="green">Green</option>
57
+ <option value="blue">Blue</option>
58
+ <option value="purple">Purple</option>
59
+ </mfp-select>
60
+ `,
61
+ };
62
+
63
+ export default meta;
64
+
65
+ type Story = StoryObj<Args>;
66
+
67
+ export const Default: Story = {};
68
+
69
+ export const WithHint: Story = {
70
+ args: { hint: 'Choose the color used for accents.' },
71
+ };
72
+
73
+ export const WithError: Story = {
74
+ args: { error: 'Please pick a color.' },
75
+ };
76
+
77
+ export const Preselected: Story = {
78
+ args: { value: 'green', placeholder: '' },
79
+ };
80
+
81
+ export const Required: Story = {
82
+ args: { required: true },
83
+ };
84
+
85
+ export const Sizes: Story = {
86
+ parameters: { controls: { disable: true } },
87
+ render: () => html`
88
+ <div style="display: flex; flex-direction: column; gap: 16px; max-width: 320px;">
89
+ <mfp-select size="sm" label="Small" placeholder="sm">
90
+ <option>One</option>
91
+ <option>Two</option>
92
+ </mfp-select>
93
+ <mfp-select size="md" label="Medium" placeholder="md">
94
+ <option>One</option>
95
+ <option>Two</option>
96
+ </mfp-select>
97
+ <mfp-select size="lg" label="Large" placeholder="lg">
98
+ <option>One</option>
99
+ <option>Two</option>
100
+ </mfp-select>
101
+ </div>
102
+ `,
103
+ };
104
+
105
+ export const Disabled: Story = {
106
+ args: { disabled: true, value: 'red', placeholder: '' },
107
+ };
108
+
109
+ export const WithGroups: Story = {
110
+ parameters: { controls: { disable: true } },
111
+ render: () => html`
112
+ <mfp-select label="Pick a fruit" placeholder="Choose…" style="max-width: 320px;">
113
+ <optgroup label="Citrus">
114
+ <option value="orange">Orange</option>
115
+ <option value="lemon">Lemon</option>
116
+ <option value="lime">Lime</option>
117
+ </optgroup>
118
+ <optgroup label="Berries">
119
+ <option value="strawberry">Strawberry</option>
120
+ <option value="blueberry">Blueberry</option>
121
+ </optgroup>
122
+ </mfp-select>
123
+ `,
124
+ };
package/src/select.ts ADDED
@@ -0,0 +1,286 @@
1
+ import { LitElement, html, css, nothing } from 'lit';
2
+ import { customElement, property, query } from 'lit/decorators.js';
3
+
4
+ export type SelectSize = 'sm' | 'md' | 'lg';
5
+
6
+ let selectIdCounter = 0;
7
+
8
+ /**
9
+ * `<mfp-select>` wraps a native `<select>` element. Children projected into
10
+ * the default slot are forwarded into the real select — usage:
11
+ *
12
+ * <mfp-select label="Color">
13
+ * <option value="red">Red</option>
14
+ * <option value="green">Green</option>
15
+ * </mfp-select>
16
+ *
17
+ * Using the native control means full keyboard a11y, screen reader support,
18
+ * and mobile-native pickers come for free. The visual chrome (border, focus
19
+ * ring, chevron) is styled via shadow CSS using design tokens.
20
+ */
21
+ @customElement('mfp-select')
22
+ export class MfpSelect extends LitElement {
23
+ static override styles = css`
24
+ :host {
25
+ display: block;
26
+ font-family: var(--font-family-sans, system-ui, -apple-system, sans-serif);
27
+ color: var(--color-text-default, #111827);
28
+ }
29
+
30
+ :host([disabled]) {
31
+ opacity: 0.6;
32
+ }
33
+
34
+ label {
35
+ display: block;
36
+ font-size: var(--font-size-sm, 14px);
37
+ font-weight: var(--font-weight-medium, 500);
38
+ line-height: var(--font-line-height-tight, 1.2);
39
+ margin-bottom: var(--size-spacing-2, 8px);
40
+ }
41
+
42
+ .required {
43
+ color: var(--color-status-error-solid, #dc2626);
44
+ margin-left: var(--size-spacing-1, 4px);
45
+ }
46
+
47
+ .control {
48
+ position: relative;
49
+ display: flex;
50
+ align-items: center;
51
+ background: var(--color-neutral-0, #ffffff);
52
+ border: 1px solid var(--color-border-default, #e5e7eb);
53
+ border-radius: var(--size-radius-md, 8px);
54
+ transition:
55
+ border-color var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease),
56
+ box-shadow var(--motion-duration-fast, 150ms) var(--motion-easing-standard, ease);
57
+ }
58
+
59
+ .control:focus-within {
60
+ border-color: var(--color-status-info-solid, #2563eb);
61
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
62
+ }
63
+
64
+ .control.invalid {
65
+ border-color: var(--color-status-error-solid, #dc2626);
66
+ }
67
+
68
+ .control.invalid:focus-within {
69
+ box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
70
+ }
71
+
72
+ select {
73
+ flex: 1 1 auto;
74
+ min-width: 0;
75
+ appearance: none;
76
+ -webkit-appearance: none;
77
+ background: transparent;
78
+ border: none;
79
+ outline: none;
80
+ font: inherit;
81
+ color: inherit;
82
+ cursor: pointer;
83
+ padding: var(--size-spacing-2, 8px) var(--size-spacing-9, 36px)
84
+ var(--size-spacing-2, 8px) var(--size-spacing-3, 12px);
85
+ }
86
+
87
+ select:disabled {
88
+ cursor: not-allowed;
89
+ }
90
+
91
+ .chevron {
92
+ position: absolute;
93
+ right: var(--size-spacing-3, 12px);
94
+ top: 50%;
95
+ transform: translateY(-50%);
96
+ width: 1em;
97
+ height: 1em;
98
+ color: var(--color-text-muted, #6b7280);
99
+ pointer-events: none;
100
+ }
101
+
102
+ /* Sizes — fall back to medium when no [size] attribute is set */
103
+ :host(:not([size])) select,
104
+ :host([size='md']) select {
105
+ font-size: var(--font-size-base, 16px);
106
+ min-height: 40px;
107
+ }
108
+ :host([size='sm']) select {
109
+ font-size: var(--font-size-sm, 14px);
110
+ min-height: 32px;
111
+ padding: var(--size-spacing-1, 4px) var(--size-spacing-9, 36px)
112
+ var(--size-spacing-1, 4px) var(--size-spacing-3, 12px);
113
+ }
114
+ :host([size='lg']) select {
115
+ font-size: var(--font-size-lg, 18px);
116
+ min-height: 48px;
117
+ padding: var(--size-spacing-3, 12px) var(--size-spacing-10, 40px)
118
+ var(--size-spacing-3, 12px) var(--size-spacing-4, 16px);
119
+ }
120
+
121
+ .hint,
122
+ .error {
123
+ margin: var(--size-spacing-2, 8px) 0 0;
124
+ font-size: var(--font-size-sm, 14px);
125
+ line-height: var(--font-line-height-tight, 1.2);
126
+ }
127
+
128
+ .hint {
129
+ color: var(--color-text-muted, #6b7280);
130
+ }
131
+
132
+ .error {
133
+ color: var(--color-status-error-solid, #dc2626);
134
+ }
135
+
136
+ /* Hide the slot — its children get moved into the native select */
137
+ .options-source {
138
+ display: none;
139
+ }
140
+
141
+ @media (prefers-reduced-motion: reduce) {
142
+ .control {
143
+ transition: none;
144
+ }
145
+ }
146
+ `;
147
+
148
+ @property({ reflect: true })
149
+ size: SelectSize = 'md';
150
+
151
+ @property()
152
+ value = '';
153
+
154
+ @property()
155
+ name = '';
156
+
157
+ @property()
158
+ label = '';
159
+
160
+ @property()
161
+ placeholder = '';
162
+
163
+ @property()
164
+ hint = '';
165
+
166
+ @property()
167
+ error = '';
168
+
169
+ @property({ type: Boolean, reflect: true })
170
+ disabled = false;
171
+
172
+ @property({ type: Boolean, reflect: true })
173
+ required = false;
174
+
175
+ private _id = `mfp-select-${++selectIdCounter}`;
176
+
177
+ @query('select')
178
+ private _selectEl!: HTMLSelectElement;
179
+
180
+ private _onChange = (e: Event) => {
181
+ const select = e.target as HTMLSelectElement;
182
+ this.value = select.value;
183
+ this.dispatchEvent(
184
+ new CustomEvent('change', {
185
+ bubbles: true,
186
+ composed: true,
187
+ detail: { value: select.value },
188
+ }),
189
+ );
190
+ };
191
+
192
+ /** Move slotted <option> elements into the real <select> when they change. */
193
+ private _onSlotChange = (e: Event) => {
194
+ const slot = e.target as HTMLSlotElement;
195
+ const select = this._selectEl;
196
+ if (!select) return;
197
+
198
+ const currentValue = this.value;
199
+ const optionLikeNodes = slot
200
+ .assignedNodes({ flatten: true })
201
+ .filter(
202
+ (n): n is HTMLElement =>
203
+ n.nodeType === Node.ELEMENT_NODE &&
204
+ ((n as HTMLElement).tagName === 'OPTION' ||
205
+ (n as HTMLElement).tagName === 'OPTGROUP'),
206
+ );
207
+
208
+ // Replace existing real options (other than the placeholder, which lives in the template)
209
+ const placeholder = select.querySelector('option[data-mfp-placeholder]');
210
+ select.textContent = '';
211
+ if (placeholder) select.appendChild(placeholder);
212
+ for (const node of optionLikeNodes) {
213
+ select.appendChild(node.cloneNode(true));
214
+ }
215
+
216
+ // Restore the value (reflect attribute → DOM value)
217
+ select.value = currentValue;
218
+ };
219
+
220
+ override render() {
221
+ const invalid = this.error.length > 0;
222
+ const inputId = this._id;
223
+ const hintId = `${inputId}-hint`;
224
+ const errorId = `${inputId}-error`;
225
+ const describedBy = invalid ? errorId : this.hint ? hintId : undefined;
226
+
227
+ return html`
228
+ ${this.label
229
+ ? html`<label part="label" for=${inputId}>
230
+ ${this.label}
231
+ ${this.required
232
+ ? html`<span class="required" aria-hidden="true">*</span>`
233
+ : nothing}
234
+ </label>`
235
+ : nothing}
236
+ <div part="control" class="control ${invalid ? 'invalid' : ''}">
237
+ <select
238
+ id=${inputId}
239
+ part="select"
240
+ .value=${this.value}
241
+ name=${this.name}
242
+ ?disabled=${this.disabled}
243
+ ?required=${this.required}
244
+ aria-invalid=${invalid ? 'true' : 'false'}
245
+ aria-describedby=${describedBy ?? nothing}
246
+ @change=${this._onChange}
247
+ >
248
+ ${this.placeholder
249
+ ? html`<option value="" disabled selected hidden data-mfp-placeholder>
250
+ ${this.placeholder}
251
+ </option>`
252
+ : nothing}
253
+ </select>
254
+ <svg
255
+ class="chevron"
256
+ part="chevron"
257
+ viewBox="0 0 16 16"
258
+ fill="none"
259
+ aria-hidden="true"
260
+ >
261
+ <path
262
+ d="M4 6l4 4 4-4"
263
+ stroke="currentColor"
264
+ stroke-width="1.5"
265
+ stroke-linecap="round"
266
+ stroke-linejoin="round"
267
+ />
268
+ </svg>
269
+ </div>
270
+ <div class="options-source">
271
+ <slot @slotchange=${this._onSlotChange}></slot>
272
+ </div>
273
+ ${invalid
274
+ ? html`<p part="error" id=${errorId} class="error" role="alert">${this.error}</p>`
275
+ : this.hint
276
+ ? html`<p part="hint" id=${hintId} class="hint">${this.hint}</p>`
277
+ : nothing}
278
+ `;
279
+ }
280
+ }
281
+
282
+ declare global {
283
+ interface HTMLElementTagNameMap {
284
+ 'mfp-select': MfpSelect;
285
+ }
286
+ }