@14ch/svelte-ui 0.0.68 → 0.0.70

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.
@@ -1,7 +1,10 @@
1
1
  <!-- Select.svelte -->
2
2
 
3
3
  <script lang="ts">
4
+ import { tick } from 'svelte';
4
5
  import Icon from './Icon.svelte';
6
+ import Popup from './Popup.svelte';
7
+ import { announceSelection } from '../utils/accessibility';
5
8
  import { t } from '../i18n';
6
9
  import type {
7
10
  FocusHandler,
@@ -11,59 +14,27 @@
11
14
  PointerHandler,
12
15
  BivariantValueHandler
13
16
  } from '../types/callbackHandlers';
14
- import type { Option } from '../types/options';
15
- import type { HTMLSelectAttributes } from 'svelte/elements';
17
+ import type { Option, OptionValue } from '../types/options';
18
+ import type { CloseReason, PopupPosition } from '../types/propOptions';
16
19
 
17
20
  // =========================================================================
18
21
  // Props, States & Constants
19
22
  // =========================================================================
20
- export type SelectProps = Omit<
21
- HTMLSelectAttributes,
22
- // 独自の型で再定義するためベースの HTML 属性から除外
23
- | 'value'
24
- | 'id'
25
- | 'tabindex'
26
- | 'size'
27
- // 独自ハンドラ型(callbackHandlers)で再定義するイベント系
28
- | 'onfocus'
29
- | 'onblur'
30
- | 'onkeydown'
31
- | 'onkeyup'
32
- | 'onclick'
33
- | 'onmousedown'
34
- | 'onmouseup'
35
- | 'onmouseenter'
36
- | 'onmouseleave'
37
- | 'onmouseover'
38
- | 'onmouseout'
39
- | 'oncontextmenu'
40
- | 'onauxclick'
41
- | 'ontouchstart'
42
- | 'ontouchend'
43
- | 'ontouchmove'
44
- | 'ontouchcancel'
45
- | 'onpointerdown'
46
- | 'onpointerup'
47
- | 'onpointerenter'
48
- | 'onpointerleave'
49
- | 'onpointermove'
50
- | 'onpointercancel'
51
- | 'onchange'
52
- > & {
23
+ export type SelectProps = {
53
24
  // 基本プロパティ
54
25
  name?: string;
55
- value: string | number | null | undefined;
56
- /** `{ label, value, disabled? }[]` */
57
- options: Option[];
26
+ /** Supports `bind:value`. */
27
+ value: OptionValue;
28
+ /** `{ label, value, disabled? }[]` or `'separator'` for dividers. */
29
+ options: (Option | 'separator')[];
58
30
 
59
31
  // HTML属性系
60
32
  id?: string | null;
61
33
  ariaLabel?: string;
62
34
  tabindex?: number | null;
63
35
  placeholder?: string;
36
+
64
37
  // スタイル/レイアウト
65
- /** Number of visible rows (multi-line mode). @default null */
66
- size?: number | null;
67
38
  /** Renders inline. @default false */
68
39
  inline?: boolean;
69
40
  /** @default 'outline' */
@@ -76,6 +47,10 @@
76
47
  disabled?: boolean;
77
48
  required?: boolean;
78
49
 
50
+ // ポップアップ
51
+ /** @default 'bottom-left' */
52
+ position?: PopupPosition;
53
+
79
54
  // フォーカスイベント
80
55
  onfocus?: FocusHandler;
81
56
  onblur?: FocusHandler;
@@ -110,7 +85,7 @@
110
85
  onpointercancel?: PointerHandler;
111
86
 
112
87
  // 入力イベント
113
- onchange?: BivariantValueHandler<string | number | null | undefined>;
88
+ onchange?: BivariantValueHandler<OptionValue>;
114
89
  };
115
90
 
116
91
  let {
@@ -126,7 +101,6 @@
126
101
  placeholder = '',
127
102
 
128
103
  // スタイル/レイアウト
129
- size = null,
130
104
  inline = false,
131
105
  focusStyle = 'outline',
132
106
  fullWidth = false,
@@ -137,6 +111,9 @@
137
111
  disabled = false,
138
112
  required = false,
139
113
 
114
+ // ポップアップ
115
+ position = 'bottom-left',
116
+
140
117
  // フォーカスイベント
141
118
  onfocus = () => {}, // No params for type inference
142
119
  onblur = () => {}, // No params for type inference
@@ -171,151 +148,212 @@
171
148
  onpointercancel = () => {}, // No params for type inference
172
149
 
173
150
  // 入力イベント
174
- onchange = () => {}, // No params for type inference
175
-
176
- // その他
177
- ...restProps
151
+ onchange = () => {} // No params for type inference
178
152
  }: SelectProps = $props();
179
153
 
180
- let isFocused: boolean = $state(false);
181
- let selectRef = $state<HTMLSelectElement>();
154
+ const TYPEAHEAD_RESET_MS = 600;
155
+
156
+ let popupRef = $state<Popup>();
157
+ let triggerEl = $state<HTMLButtonElement>();
158
+ let listEl = $state<HTMLUListElement>();
159
+ let optionEls: HTMLLIElement[] = $state([]);
160
+ let isPopupOpen = $state(false);
161
+ let isFocused = $state(false);
162
+ let triggerWidth = $state(0);
163
+ /** ハイライト中の選択肢の options 内インデックス(未選択は -1) */
164
+ let activeIndex = $state(-1);
165
+ let typeaheadBuffer = '';
166
+ let typeaheadTimer: ReturnType<typeof setTimeout> | undefined;
182
167
 
183
168
  // =========================================================================
184
- // Methods
169
+ // $derived
185
170
  // =========================================================================
186
- const handleFocus = (event: FocusEvent) => {
187
- if (disabled) return;
188
- isFocused = true;
189
- onfocus?.(event);
190
- };
191
-
192
- const handleBlur = (event: FocusEvent) => {
193
- if (disabled) return;
194
- isFocused = false;
195
- onblur?.(event);
196
- };
171
+ // アイコンの領域は customStyle で消されないよう、customStyle より後ろに指定する
172
+ const triggerStyle = $derived(
173
+ [customStyle, `padding-right: var(--svelte-ui-select-icon-space${inline ? '-inline' : ''})`]
174
+ .filter(Boolean)
175
+ .join('; ')
176
+ );
177
+
178
+ const fallbackId = `select-${Math.random().toString(36).substring(2, 15)}`;
179
+ const baseId = $derived(id ?? fallbackId);
180
+ const listboxId = $derived(`${baseId}-listbox`);
181
+ const accessibleName = $derived(ariaLabel ?? t('select.accessibleName'));
182
+
183
+ const selectedOption = $derived(
184
+ options.find((option) => option !== 'separator' && option.value === value) as Option | undefined
185
+ );
186
+
187
+ /** キーボード操作で移動できる選択肢の options 内インデックス */
188
+ const navigableIndexes = $derived(
189
+ options.reduce<number[]>((indexes, option, index) => {
190
+ if (option !== 'separator' && !option.disabled) {
191
+ indexes.push(index);
192
+ }
193
+ return indexes;
194
+ }, [])
195
+ );
197
196
 
198
- const handleKeydown = (event: KeyboardEvent) => {
199
- if (disabled) return;
200
- onkeydown?.(event);
201
- };
202
-
203
- const handleKeyup = (event: KeyboardEvent) => {
204
- if (disabled) return;
205
- onkeyup?.(event);
206
- };
207
-
208
- // マウスイベント
209
- const handleClick = (event: MouseEvent) => {
210
- if (disabled) return;
211
- onclick?.(event);
212
- };
197
+ // =========================================================================
198
+ // Methods
199
+ // =========================================================================
200
+ const getOptionId = (index: number): string => `${baseId}-option-${index}`;
213
201
 
214
- const handleMouseDown = (event: MouseEvent) => {
215
- if (disabled) return;
216
- onmousedown?.(event);
202
+ const getOptionAt = (index: number): Option | undefined => {
203
+ const option = options[index];
204
+ return option === 'separator' ? undefined : option;
217
205
  };
218
206
 
219
- const handleMouseUp = (event: MouseEvent) => {
220
- if (disabled) return;
221
- onmouseup?.(event);
207
+ const setActiveIndex = async (index: number) => {
208
+ activeIndex = index;
209
+ await tick();
210
+ optionEls[index]?.scrollIntoView({ block: 'nearest' });
222
211
  };
223
212
 
224
- const handleMouseEnter = (event: MouseEvent) => {
225
- if (disabled) return;
226
- onmouseenter?.(event);
213
+ const moveActiveIndex = (step: number) => {
214
+ if (navigableIndexes.length === 0) return;
215
+ const current = navigableIndexes.indexOf(activeIndex);
216
+ const next =
217
+ current === -1
218
+ ? step > 0
219
+ ? 0
220
+ : navigableIndexes.length - 1
221
+ : (current + step + navigableIndexes.length) % navigableIndexes.length;
222
+ setActiveIndex(navigableIndexes[next]);
227
223
  };
228
224
 
229
- const handleMouseLeave = (event: MouseEvent) => {
230
- if (disabled) return;
231
- onmouseleave?.(event);
225
+ /** 入力した文字で始まる選択肢へ移動する(ネイティブ select 相当の挙動) */
226
+ const moveByTypeahead = (key: string) => {
227
+ clearTimeout(typeaheadTimer);
228
+ typeaheadBuffer += key.toLowerCase();
229
+ typeaheadTimer = setTimeout(() => {
230
+ typeaheadBuffer = '';
231
+ }, TYPEAHEAD_RESET_MS);
232
+
233
+ // 1文字目は現在位置の次から探し、同じ文字の連打で候補を巡回できるようにする
234
+ const offset = typeaheadBuffer.length === 1 ? navigableIndexes.indexOf(activeIndex) + 1 : 0;
235
+ const searchOrder = [...navigableIndexes.slice(offset), ...navigableIndexes.slice(0, offset)];
236
+ const matched = searchOrder.find((index) =>
237
+ getOptionAt(index)?.label.toLowerCase().startsWith(typeaheadBuffer)
238
+ );
239
+
240
+ if (matched !== undefined) {
241
+ setActiveIndex(matched);
242
+ }
232
243
  };
233
244
 
234
- const handleMouseOver = (event: MouseEvent) => {
235
- if (disabled) return;
236
- onmouseover?.(event);
237
- };
245
+ const selectOption = (option: Option) => {
246
+ if (disabled || option.disabled) return;
238
247
 
239
- const handleMouseOut = (event: MouseEvent) => {
240
- if (disabled) return;
241
- onmouseout?.(event);
248
+ value = option.value;
249
+ announceSelection(option.label);
250
+ onchange?.(value);
251
+ popupRef?.close();
242
252
  };
243
253
 
244
- const handleContextMenu = (event: MouseEvent) => {
245
- if (disabled) return;
246
- oncontextmenu?.(event);
254
+ const handleFocus = (event: FocusEvent) => {
255
+ isFocused = true;
256
+ onfocus?.(event);
247
257
  };
248
258
 
249
- const handleAuxClick = (event: MouseEvent) => {
250
- if (disabled) return;
251
- onauxclick?.(event);
259
+ const handleBlur = (event: FocusEvent) => {
260
+ isFocused = false;
261
+ onblur?.(event);
252
262
  };
253
263
 
254
- // タッチイベント
255
- const handleTouchStart = (event: TouchEvent) => {
256
- if (disabled) return;
257
- ontouchstart?.(event);
264
+ const handleTriggerClick = (event: MouseEvent) => {
265
+ popupRef?.toggle();
266
+ onclick?.(event);
258
267
  };
259
268
 
260
- const handleTouchEnd = (event: TouchEvent) => {
261
- if (disabled) return;
262
- ontouchend?.(event);
263
- };
269
+ const handleTriggerKeydown = (event: KeyboardEvent) => {
270
+ onkeydown?.(event);
264
271
 
265
- const handleTouchMove = (event: TouchEvent) => {
266
- if (disabled) return;
267
- ontouchmove?.(event);
272
+ switch (event.key) {
273
+ case 'Enter':
274
+ case ' ':
275
+ case 'ArrowDown':
276
+ case 'ArrowUp':
277
+ event.preventDefault();
278
+ popupRef?.open();
279
+ break;
280
+ }
268
281
  };
269
282
 
270
- const handleTouchCancel = (event: TouchEvent) => {
271
- if (disabled) return;
272
- ontouchcancel?.(event);
273
- };
283
+ const handleListClick = (event: MouseEvent) => {
284
+ const optionEl = (event.target as HTMLElement | null)?.closest<HTMLElement>(
285
+ '[data-option-index]'
286
+ );
287
+ if (!optionEl) return;
274
288
 
275
- // ポインターイベント
276
- const handlePointerDown = (event: PointerEvent) => {
277
- if (disabled) return;
278
- onpointerdown?.(event);
289
+ const option = getOptionAt(Number(optionEl.dataset.optionIndex));
290
+ if (option) selectOption(option);
279
291
  };
280
292
 
281
- const handlePointerUp = (event: PointerEvent) => {
282
- if (disabled) return;
283
- onpointerup?.(event);
293
+ const handleListKeydown = (event: KeyboardEvent) => {
294
+ switch (event.key) {
295
+ case 'ArrowDown':
296
+ event.preventDefault();
297
+ moveActiveIndex(1);
298
+ break;
299
+ case 'ArrowUp':
300
+ event.preventDefault();
301
+ moveActiveIndex(-1);
302
+ break;
303
+ case 'Home':
304
+ event.preventDefault();
305
+ if (navigableIndexes.length > 0) setActiveIndex(navigableIndexes[0]);
306
+ break;
307
+ case 'End':
308
+ event.preventDefault();
309
+ if (navigableIndexes.length > 0)
310
+ setActiveIndex(navigableIndexes[navigableIndexes.length - 1]);
311
+ break;
312
+ case 'Enter':
313
+ case ' ': {
314
+ event.preventDefault();
315
+ const option = getOptionAt(activeIndex);
316
+ if (option) selectOption(option);
317
+ break;
318
+ }
319
+ case 'Tab':
320
+ popupRef?.close();
321
+ break;
322
+ default:
323
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
324
+ event.preventDefault();
325
+ moveByTypeahead(event.key);
326
+ }
327
+ break;
328
+ }
284
329
  };
285
330
 
286
- const handlePointerEnter = (event: PointerEvent) => {
287
- if (disabled) return;
288
- onpointerenter?.(event);
289
- };
331
+ const handlePopupOpen = () => {
332
+ triggerWidth = triggerEl?.offsetWidth ?? 0;
290
333
 
291
- const handlePointerLeave = (event: PointerEvent) => {
292
- if (disabled) return;
293
- onpointerleave?.(event);
294
- };
334
+ const selectedIndex = options.findIndex(
335
+ (option) => option !== 'separator' && option.value === value && !option.disabled
336
+ );
337
+ setActiveIndex(selectedIndex >= 0 ? selectedIndex : (navigableIndexes[0] ?? -1));
295
338
 
296
- const handlePointerMove = (event: PointerEvent) => {
297
- if (disabled) return;
298
- onpointermove?.(event);
339
+ tick().then(() => {
340
+ listEl?.focus();
341
+ });
299
342
  };
300
343
 
301
- const handlePointerCancel = (event: PointerEvent) => {
302
- if (disabled) return;
303
- onpointercancel?.(event);
304
- };
344
+ const handlePopupClose = (reason: CloseReason) => {
345
+ activeIndex = -1;
346
+ typeaheadBuffer = '';
347
+ clearTimeout(typeaheadTimer);
305
348
 
306
- // 選択イベント
307
- const handleChange = () => {
308
- onchange?.(value);
309
- // 選択完了後にフォーカスを解除
310
- if (selectRef) {
311
- selectRef.blur();
349
+ if (reason !== 'outside') {
350
+ triggerEl?.focus();
312
351
  }
313
352
  };
314
353
  </script>
315
354
 
316
355
  <div
317
- class="select
318
- select--focus-{focusStyle}"
356
+ class="select select--focus-{focusStyle}"
319
357
  class:select--inline={inline}
320
358
  class:select--full-width={fullWidth}
321
359
  class:select--disabled={disabled}
@@ -323,198 +361,334 @@ select--focus-{focusStyle}"
323
361
  class:select--rounded={rounded}
324
362
  data-testid="select"
325
363
  >
326
- <select
327
- bind:this={selectRef}
364
+ <button
365
+ bind:this={triggerEl}
328
366
  {id}
329
- {name}
330
- aria-label={ariaLabel ?? t('select.accessibleName')}
331
- bind:value
332
- {disabled}
333
- {required}
367
+ type="button"
368
+ class="select__trigger"
369
+ role="combobox"
370
+ aria-expanded={isPopupOpen}
371
+ aria-haspopup="listbox"
372
+ aria-controls={isPopupOpen ? listboxId : undefined}
373
+ aria-label={accessibleName}
374
+ aria-required={required ? 'true' : undefined}
334
375
  {tabindex}
335
- {size}
336
- style={customStyle}
376
+ {disabled}
377
+ style={triggerStyle}
337
378
  onfocus={handleFocus}
338
379
  onblur={handleBlur}
339
- onkeydown={handleKeydown}
340
- onkeyup={handleKeyup}
341
- onclick={handleClick}
342
- onmousedown={handleMouseDown}
343
- onmouseup={handleMouseUp}
344
- onmouseenter={handleMouseEnter}
345
- onmouseleave={handleMouseLeave}
346
- onmouseover={handleMouseOver}
347
- onmouseout={handleMouseOut}
348
- oncontextmenu={handleContextMenu}
349
- onauxclick={handleAuxClick}
350
- ontouchstart={handleTouchStart}
351
- ontouchend={handleTouchEnd}
352
- ontouchmove={handleTouchMove}
353
- ontouchcancel={handleTouchCancel}
354
- onpointerdown={handlePointerDown}
355
- onpointerup={handlePointerUp}
356
- onpointerenter={handlePointerEnter}
357
- onpointerleave={handlePointerLeave}
358
- onpointermove={handlePointerMove}
359
- onpointercancel={handlePointerCancel}
360
- onchange={handleChange}
361
- {...restProps}
380
+ onclick={handleTriggerClick}
381
+ onkeydown={handleTriggerKeydown}
382
+ {onkeyup}
383
+ {onmousedown}
384
+ {onmouseup}
385
+ {onmouseenter}
386
+ {onmouseleave}
387
+ {onmouseover}
388
+ {onmouseout}
389
+ {oncontextmenu}
390
+ {onauxclick}
391
+ {ontouchstart}
392
+ {ontouchend}
393
+ {ontouchmove}
394
+ {ontouchcancel}
395
+ {onpointerdown}
396
+ {onpointerup}
397
+ {onpointerenter}
398
+ {onpointerleave}
399
+ {onpointermove}
400
+ {onpointercancel}
362
401
  >
363
- <!-- プレースホルダーオプション -->
364
- {#if placeholder}
365
- <option value="" disabled selected={value === undefined || value === null}>
366
- {placeholder}
367
- </option>
368
- {/if}
369
- <!-- 通常のオプション -->
370
- {#each options as option (option.value)}
371
- <option value={option.value} disabled={option.disabled}>
372
- {option.label}
373
- </option>
374
- {/each}
375
- </select>
376
- <!-- ドロップダウンアイコン -->
377
- <div class="select__dropdown-icon" aria-hidden="true">
378
- <Icon>arrow_drop_down</Icon>
379
- </div>
402
+ <span class="select__inner">
403
+ {#if selectedOption}
404
+ <span class="select__display-text">{selectedOption.label}</span>
405
+ {:else if placeholder}
406
+ <span class="select__placeholder">{placeholder}</span>
407
+ {/if}
408
+ <span class="select__dropdown-icon" aria-hidden="true">
409
+ <Icon>arrow_drop_down</Icon>
410
+ </span>
411
+ </span>
412
+ </button>
413
+
414
+ <!-- フォーム送信用(ネイティブ select を使わないため値を hidden で送る) -->
415
+ {#if name}
416
+ <input type="hidden" {name} value={value ?? ''} />
417
+ {/if}
418
+
419
+ <Popup
420
+ bind:this={popupRef}
421
+ bind:isOpen={isPopupOpen}
422
+ anchorElement={triggerEl}
423
+ {position}
424
+ role="presentation"
425
+ mobileFullscreen={true}
426
+ onOpen={handlePopupOpen}
427
+ onClose={handlePopupClose}
428
+ margin={4}
429
+ id={id ? `${id}-popup` : undefined}
430
+ >
431
+ <ul
432
+ bind:this={listEl}
433
+ id={listboxId}
434
+ class="select__options"
435
+ role="listbox"
436
+ tabindex="-1"
437
+ aria-label={accessibleName}
438
+ aria-activedescendant={activeIndex >= 0 ? getOptionId(activeIndex) : undefined}
439
+ style:--internal-select-trigger-width="{triggerWidth}px"
440
+ onclick={handleListClick}
441
+ onkeydown={handleListKeydown}
442
+ >
443
+ {#each options as option, index (option === 'separator' ? `separator-${index}` : option.value)}
444
+ {#if option === 'separator'}
445
+ <li class="select__separator" role="separator"></li>
446
+ {:else}
447
+ <li
448
+ bind:this={optionEls[index]}
449
+ id={getOptionId(index)}
450
+ data-option-index={index}
451
+ class="select__option"
452
+ class:select__option--selected={option.value === value}
453
+ class:select__option--active={index === activeIndex}
454
+ class:select__option--disabled={option.disabled}
455
+ role="option"
456
+ aria-selected={option.value === value}
457
+ aria-disabled={option.disabled ? 'true' : undefined}
458
+ onmouseenter={() => {
459
+ if (!option.disabled) activeIndex = index;
460
+ }}
461
+ >
462
+ <span class="select__option-check">
463
+ {#if option.value === value}
464
+ <Icon size="var(--svelte-ui-select-option-check-size)">check</Icon>
465
+ {/if}
466
+ </span>
467
+ <span class="select__option-label">{option.label}</span>
468
+ </li>
469
+ {/if}
470
+ {/each}
471
+ </ul>
472
+ </Popup>
380
473
  </div>
381
474
 
382
- <style>
383
- /* =============================================
475
+ <style>@charset "UTF-8";
476
+ /* =============================================
384
477
  * 基本構造・レイアウト
385
478
  * ============================================= */
386
- .select {
387
- display: inline-block;
388
- position: relative;
389
- width: auto;
390
- max-width: 100%;
391
- vertical-align: top;
392
- }
393
-
394
- /* =============================================
395
- * 基本コンポーネント
479
+ .select {
480
+ display: inline-block;
481
+ position: relative;
482
+ width: auto;
483
+ max-width: 100%;
484
+ vertical-align: top;
485
+ }
486
+
487
+ /* =============================================
488
+ * トリガーボタン
396
489
  * ============================================= */
397
- select {
398
- width: 100%;
399
- min-height: var(--svelte-ui-select-height);
400
- padding: var(--svelte-ui-select-padding);
401
- padding-right: var(--svelte-ui-select-icon-space);
402
- background: transparent;
403
- border: none;
404
- border-radius: 0;
405
- font-family: inherit;
406
- font-size: inherit;
407
- font-weight: inherit;
408
- color: inherit;
409
- line-height: inherit;
410
- text-align: inherit;
411
- cursor: pointer;
412
- -webkit-appearance: none;
413
- -moz-appearance: none;
414
- appearance: none;
415
- }
416
-
417
- .select__dropdown-icon {
418
- display: flex;
419
- justify-content: center;
420
- align-items: center;
421
- position: absolute;
422
- top: 50%;
423
- right: 4px;
424
- width: 32px;
425
- height: 32px;
426
- transform: translateY(-50%);
427
- color: var(--svelte-ui-select-dropdown-icon-color);
428
- pointer-events: none;
429
- }
430
-
431
- /* =============================================
490
+ .select__trigger {
491
+ display: flex;
492
+ align-items: center;
493
+ width: 100%;
494
+ min-height: var(--svelte-ui-select-height);
495
+ padding: var(--svelte-ui-select-padding);
496
+ background: transparent;
497
+ border: none;
498
+ font-family: inherit;
499
+ font-size: inherit;
500
+ font-weight: inherit;
501
+ color: inherit;
502
+ line-height: inherit;
503
+ text-align: left;
504
+ cursor: pointer;
505
+ }
506
+
507
+ .select__inner {
508
+ flex: 1;
509
+ align-self: stretch;
510
+ display: flex;
511
+ align-items: center;
512
+ min-width: 0;
513
+ }
514
+
515
+ .select__display-text,
516
+ .select__placeholder {
517
+ flex: 1;
518
+ display: block;
519
+ overflow: hidden;
520
+ text-overflow: ellipsis;
521
+ white-space: nowrap;
522
+ }
523
+
524
+ .select__placeholder {
525
+ color: var(--svelte-ui-select-placeholder-color);
526
+ }
527
+
528
+ .select__dropdown-icon {
529
+ display: flex;
530
+ justify-content: center;
531
+ align-items: center;
532
+ position: absolute;
533
+ top: 50%;
534
+ right: 4px;
535
+ transform: translateY(-50%);
536
+ width: 32px;
537
+ height: 32px;
538
+ color: var(--svelte-ui-select-dropdown-icon-color);
539
+ pointer-events: none;
540
+ }
541
+
542
+ /* =============================================
432
543
  * レイアウトバリエーション
433
544
  * ============================================= */
434
- .select--full-width {
435
- width: 100%;
436
- }
437
-
438
- /* =============================================
439
- * プレースホルダー・オプション表示
440
- * ============================================= */
441
- option[value=''] {
442
- color: var(--svelte-ui-select-placeholder-color);
443
- }
545
+ .select--full-width {
546
+ width: 100%;
547
+ }
444
548
 
445
- /* =============================================
549
+ /* =============================================
446
550
  * フォーカス効果バリエーション
447
551
  * ============================================= */
448
- .select--focus-outline select:focus {
449
- outline: var(--svelte-ui-focus-outline-inner);
450
- outline-offset: var(--svelte-ui-focus-outline-offset-inner);
451
- }
452
-
453
- /* デザインバリアントの背景指定より詳細度を上げるため .select を併記 */
454
- .select.select--focus-background select:focus {
455
- outline: none;
456
- background: var(--svelte-ui-hover-overlay);
457
- }
458
-
459
- .select--focus-none select:focus {
460
- outline: none;
461
- }
462
-
463
- /* =============================================
464
- * 状態管理(disabled, readonly等)
552
+ .select--focus-outline .select__trigger:focus-visible {
553
+ outline: var(--svelte-ui-focus-outline-inner);
554
+ outline-offset: var(--svelte-ui-focus-outline-offset-inner);
555
+ }
556
+
557
+ /* デザインバリアントの背景指定より詳細度を上げるため .select を併記 */
558
+ .select.select--focus-background .select__trigger:focus-visible {
559
+ outline: none;
560
+ background: var(--svelte-ui-hover-overlay);
561
+ }
562
+
563
+ .select--focus-none .select__trigger:focus-visible {
564
+ outline: none;
565
+ }
566
+
567
+ /* =============================================
568
+ * 状態管理(disabled等)
465
569
  * ============================================= */
466
- .select--disabled {
467
- opacity: var(--svelte-ui-input-disabled-opacity);
468
- cursor: not-allowed;
469
- }
470
-
471
- .select--disabled select {
472
- cursor: not-allowed;
473
- }
474
-
475
- select:disabled {
476
- opacity: 1;
477
- }
478
-
479
- /* =============================================
570
+ .select--disabled {
571
+ opacity: var(--svelte-ui-input-disabled-opacity);
572
+ cursor: not-allowed;
573
+ }
574
+ .select--disabled .select__trigger {
575
+ cursor: not-allowed;
576
+ pointer-events: none;
577
+ }
578
+
579
+ /* =============================================
480
580
  * デザインバリアント:default
481
581
  * ============================================= */
482
- .select:not(.select--inline) {
483
- select {
484
- min-height: var(--svelte-ui-select-height);
485
- background-color: var(--svelte-ui-select-bg);
486
- box-shadow: 0 0 0 var(--svelte-ui-border-width) inset var(--svelte-ui-select-border-color);
487
- border: none;
488
- border-radius: var(--svelte-ui-select-border-radius);
489
- font-size: var(--svelte-ui-font-size);
490
- line-height: var(--svelte-ui-select-height);
491
- }
492
- }
493
-
494
- /* =============================================
582
+ .select:not(.select--inline) .select__trigger {
583
+ min-height: var(--svelte-ui-select-height);
584
+ background-color: var(--svelte-ui-select-bg);
585
+ box-shadow: 0 0 0 var(--svelte-ui-border-width) inset var(--svelte-ui-select-border-color);
586
+ border: none;
587
+ border-radius: var(--svelte-ui-select-border-radius);
588
+ font-size: var(--svelte-ui-font-size);
589
+ }
590
+
591
+ /* =============================================
495
592
  * デザインバリアント:inline
496
593
  * ============================================= */
497
- .select.select--inline {
498
- select {
499
- padding: inherit;
500
- padding-right: var(--svelte-ui-input-icon-space-inline);
501
- background: transparent;
502
- border: none;
503
- border-radius: 0;
504
- color: inherit;
505
- min-height: auto;
506
- line-height: inherit;
507
- }
508
-
509
- .select__dropdown-icon {
510
- right: 0;
511
- }
512
- }
513
-
514
- /* =============================================
594
+ .select.select--inline .select__trigger {
595
+ padding: inherit;
596
+ background: transparent;
597
+ border: none;
598
+ border-radius: 0;
599
+ color: inherit;
600
+ min-height: auto;
601
+ line-height: inherit;
602
+ }
603
+ .select.select--inline .select__inner {
604
+ align-self: auto;
605
+ align-items: flex-start;
606
+ min-height: 1lh;
607
+ }
608
+ .select.select--inline .select__dropdown-icon {
609
+ top: calc((1lh - 32px) / 2);
610
+ right: 0;
611
+ transform: none;
612
+ }
613
+
614
+ /* =============================================
515
615
  * デザインバリアント:rounded
516
616
  * ============================================= */
517
- .select--rounded:not(.select--inline) select {
518
- border-radius: var(--svelte-ui-select-border-radius-rounded);
519
- }
520
- </style>
617
+ .select--rounded:not(.select--inline) .select__trigger {
618
+ border-radius: var(--svelte-ui-select-border-radius-rounded);
619
+ }
620
+
621
+ /* =============================================
622
+ * オプションリスト(ポップアップ内)
623
+ * ============================================= */
624
+ .select__options {
625
+ list-style: none;
626
+ margin: 0;
627
+ padding: 0;
628
+ /* トリガー幅に合わせつつ、トリガーが狭いときはテーマの下限を保つ */
629
+ min-width: max(var(--internal-select-trigger-width, 0px), var(--svelte-ui-select-options-min-width));
630
+ max-height: var(--svelte-ui-select-options-max-height);
631
+ overflow-y: auto;
632
+ outline: none;
633
+ }
634
+
635
+ .select__option {
636
+ display: flex;
637
+ align-items: center;
638
+ gap: var(--svelte-ui-select-option-gap);
639
+ padding: var(--svelte-ui-select-option-padding);
640
+ color: var(--svelte-ui-text-color);
641
+ white-space: nowrap;
642
+ cursor: pointer;
643
+ transition: background-color var(--svelte-ui-transition-duration) ease;
644
+ }
645
+ @media (hover: hover) {
646
+ .select__option:hover {
647
+ background-color: var(--svelte-ui-select-option-hover-bg);
648
+ }
649
+ }
650
+ .select__option.select__option--active {
651
+ background-color: var(--svelte-ui-select-option-hover-bg);
652
+ }
653
+ .select__option.select__option--selected {
654
+ background-color: var(--svelte-ui-select-option-selected-bg);
655
+ }
656
+ .select__option.select__option--disabled {
657
+ opacity: var(--svelte-ui-input-disabled-opacity);
658
+ cursor: not-allowed;
659
+ }
660
+
661
+ .select__option-check {
662
+ display: flex;
663
+ justify-content: center;
664
+ align-items: center;
665
+ flex: 0 0 auto;
666
+ width: var(--svelte-ui-select-option-check-size);
667
+ height: var(--svelte-ui-select-option-check-size);
668
+ }
669
+
670
+ .select__separator {
671
+ height: 0;
672
+ margin: var(--svelte-ui-select-separator-margin);
673
+ border-bottom: solid 1px var(--svelte-ui-select-separator-color);
674
+ }
675
+
676
+ /* =============================================
677
+ * モバイル(タッチターゲット確保)
678
+ * ============================================= */
679
+ @media (max-width: 768px) {
680
+ .select__option {
681
+ min-height: var(--svelte-ui-touch-target);
682
+ }
683
+ }
684
+ :global(.popup--fullscreen) .select__option {
685
+ min-height: var(--svelte-ui-touch-target-lg);
686
+ padding: 16px 24px;
687
+ }
688
+
689
+ /* Reduced motion support */
690
+ @media (prefers-reduced-motion: reduce) {
691
+ .select__option {
692
+ transition: none;
693
+ }
694
+ }</style>