@14ch/svelte-ui 0.0.28 → 0.0.29

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.
@@ -358,21 +358,6 @@
358
358
  --svelte-ui-segmented-control-selected-text-color: var(--svelte-ui-text-on-filled-color);
359
359
  --svelte-ui-segmented-control-hover-overlay: var(--svelte-ui-hover-overlay);
360
360
 
361
- /* Tab */
362
- --svelte-ui-tab-min-height: var(--svelte-ui-form-height);
363
- --svelte-ui-tab-item-text-color: var(--svelte-ui-text-color);
364
- --svelte-ui-tab-item-selected-text-color: var(--svelte-ui-primary-color);
365
- --svelte-ui-tab-item-selected-bar-color: var(--svelte-ui-primary-color);
366
- --svelte-ui-tab-item-padding-x: 16px;
367
- --svelte-ui-tab-item-padding-y: 8px;
368
- --svelte-ui-tab-item-padding: var(--svelte-ui-tab-item-padding-y)
369
- var(--svelte-ui-tab-item-padding-x);
370
- --svelte-ui-tab-item-icon-gap: 8px;
371
- --svelte-ui-tab-item-selected-bar-offset: 0px;
372
- --svelte-ui-tab-item-selected-bar-height: 4px;
373
- --svelte-ui-tab-item-selected-bar-radius: 3px 3px 0 0;
374
- --svelte-ui-tab-item-disabled-opacity: var(--svelte-ui-disabled-opacity);
375
-
376
361
  /* Nav */
377
362
  --svelte-ui-nav-item-text-color: var(--svelte-ui-text-color);
378
363
  --svelte-ui-nav-item-selected-text-color: var(--svelte-ui-primary-color);
@@ -382,23 +367,42 @@
382
367
  var(--svelte-ui-primary-color) 16%,
383
368
  transparent
384
369
  );
385
- --svelte-ui-nav-item-selected-bar-color: var(--svelte-ui-primary-color);
386
- --svelte-ui-nav-item-selected-bar-height: 3px;
387
- --svelte-ui-nav-item-selected-bar-radius: 2px;
370
+ /* Nav: underline style(Tab はこれを参照するエイリアス) */
371
+ --svelte-ui-nav-item-underline-text-color: var(--svelte-ui-text-color);
372
+ --svelte-ui-nav-item-underline-selected-text-color: var(--svelte-ui-primary-color);
373
+ --svelte-ui-nav-item-underline-bar-color: var(--svelte-ui-primary-color);
374
+ --svelte-ui-nav-item-underline-bar-offset: 0px;
375
+ --svelte-ui-nav-item-underline-bar-height: 4px;
376
+ --svelte-ui-nav-item-underline-bar-radius: 3px 3px 0 0;
377
+
378
+ /* Tab */
379
+ --svelte-ui-tab-min-height: var(--svelte-ui-form-height);
388
380
  --svelte-ui-nav-item-min-height: 40px;
389
381
  --svelte-ui-nav-item-icon-gap: 8px;
390
382
  --svelte-ui-nav-item-disabled-opacity: var(--svelte-ui-disabled-opacity);
383
+ --svelte-ui-nav-item-padding-x: 12px;
384
+ --svelte-ui-nav-item-padding-y: 8px;
385
+ --svelte-ui-nav-item-padding: var(--svelte-ui-nav-item-padding-y)
386
+ var(--svelte-ui-nav-item-padding-x);
387
+ --svelte-ui-nav-item-border-radius: var(--svelte-ui-border-radius);
388
+ --svelte-ui-nav-item-label-line-height: 1.3;
391
389
  /* Nav: mobile-tab */
392
390
  --svelte-ui-nav-mobile-min-height: 56px;
393
391
  --svelte-ui-nav-mobile-item-padding: 8px;
394
392
  --svelte-ui-nav-mobile-item-icon-gap: 8px;
395
393
  --svelte-ui-nav-mobile-item-font-size: 0.75rem;
396
- --svelte-ui-nav-item-padding: 8px 12px;
397
- --svelte-ui-nav-item-border-radius: var(--svelte-ui-border-radius);
398
394
  /* Nav: vertical */
399
395
  --svelte-ui-nav-vertical-item-gap: 8px;
400
396
  /* Nav: horizontal */
401
397
  --svelte-ui-nav-horizontal-item-gap: 8px;
398
+ /* Nav: sub-menu */
399
+ --svelte-ui-nav-sub-popup-min-width: 160px;
400
+ --svelte-ui-nav-sub-popup-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
401
+ --svelte-ui-nav-sub-bar-min-height: 48px;
402
+ --svelte-ui-nav-item-child-indent: 32px;
403
+ --svelte-ui-nav-bottom-sheet-overlay-bg: rgba(0, 0, 0, 0.4);
404
+ --svelte-ui-nav-bottom-sheet-border-radius: 16px 16px 0 0;
405
+ --svelte-ui-nav-bottom-sheet-padding: 16px 8px;
402
406
 
403
407
  /* Input */
404
408
  --svelte-ui-input-height: var(--svelte-ui-form-height);
@@ -879,10 +883,10 @@
879
883
  --svelte-ui-slider-custom-thumb-focus-shadow: 0 0 0 3px Highlight;
880
884
  --svelte-ui-slider-custom-thumb-color: HighlightText;
881
885
 
882
- /* Tab */
883
- --svelte-ui-tab-item-text-color: CanvasText;
884
- --svelte-ui-tab-item-selected-text-color: Highlight;
885
- --svelte-ui-tab-item-selected-bar-color: Highlight;
886
+ /* Nav: underline */
887
+ --svelte-ui-nav-item-underline-text-color: CanvasText;
888
+ --svelte-ui-nav-item-underline-selected-text-color: Highlight;
889
+ --svelte-ui-nav-item-underline-bar-color: Highlight;
886
890
 
887
891
  /* Radio */
888
892
  --svelte-ui-radio-border-color: CanvasText;
@@ -4,7 +4,7 @@
4
4
  import NavItem from './NavItem.svelte';
5
5
  import type { NavItemSelectedStyle } from './NavItem.svelte';
6
6
  import type { MenuItem } from '../types/menuItem';
7
- import type { NavVariant } from '../types/propOptions';
7
+ import type { NavVariant, SubMenuMode } from '../types/propOptions';
8
8
  import { subscribeUrlChange } from '../utils/urlChange';
9
9
  import { getCurrentPath, matchPath } from '../utils/navPath';
10
10
  import type { IconVariant, IconWeight, IconGrade, IconOpticalSize } from '../types/icon';
@@ -14,9 +14,9 @@
14
14
  // =========================================================================
15
15
  export type NavProps = {
16
16
  // 基本プロパティ
17
- /** `{ label, href, icon?, disabled? }[]` */
17
+ /** `{ label, href, icon?, children?, disabled? }[]` */
18
18
  navItems?: MenuItem[];
19
- /** Layout variant. @default 'tab' */
19
+ /** Layout variant. @default 'horizontal' */
20
20
  variant?: NavVariant;
21
21
  /** Prepended to each item's href for active-state matching. */
22
22
  pathPrefix?: string;
@@ -39,6 +39,10 @@
39
39
  /** Visual style for the selected item. */
40
40
  selectedStyle?: NavItemSelectedStyle;
41
41
  gap?: number | string;
42
+ /** Sub-menu display mode for items that have children. @default 'popup' */
43
+ subMenuMode?: SubMenuMode;
44
+ /** Show chevron icon on parent items. @default true */
45
+ chevron?: boolean;
42
46
 
43
47
  // ARIA/アクセシビリティ
44
48
  ariaLabel?: string;
@@ -48,7 +52,7 @@
48
52
  let {
49
53
  // 基本プロパティ
50
54
  navItems = [],
51
- variant = 'tab',
55
+ variant = 'horizontal',
52
56
  pathPrefix = '',
53
57
  customPathMatcher,
54
58
  currentPath,
@@ -66,6 +70,8 @@
66
70
  // スタイル/レイアウト
67
71
  selectedStyle,
68
72
  gap,
73
+ subMenuMode = 'popup',
74
+ chevron,
69
75
 
70
76
  // ARIA/アクセシビリティ
71
77
  ariaLabel,
@@ -73,6 +79,11 @@
73
79
  }: NavProps = $props();
74
80
 
75
81
  let resolvedCurrentPath = $state('');
82
+ let navEl: HTMLElement | undefined = $state();
83
+ let subBarEl: HTMLElement | undefined = $state();
84
+
85
+ // bar/accordion モード: 展開中の親アイテム($state.raw で Proxy ラップを避け === 比較を正常にする)
86
+ let expandedParent: MenuItem | null = $state.raw(null);
76
87
 
77
88
  // =========================================================================
78
89
  // Effects
@@ -84,25 +95,58 @@
84
95
  $effect(() => {
85
96
  return subscribeUrlChange(() => {
86
97
  resolvedCurrentPath = getCurrentPath(currentPath);
98
+ if (subMenuMode !== 'accordion') expandedParent = null;
87
99
  });
88
100
  });
89
101
 
102
+ // accordion モード: アクティブな子を持つ親を自動展開
103
+ $effect(() => {
104
+ if (subMenuMode !== 'accordion' || !resolvedCurrentPath) return;
105
+ const activeParent = navItems.find((item) =>
106
+ item.children?.some(
107
+ (child) =>
108
+ child.href &&
109
+ matchPath(resolvedCurrentPath, child.href, child, pathPrefix, customPathMatcher)
110
+ )
111
+ );
112
+ if (activeParent) expandedParent = activeParent;
113
+ });
114
+
90
115
  // =========================================================================
91
116
  // Methods
92
117
  // =========================================================================
93
- const handleKeyDown = (event: KeyboardEvent) => {
94
- if (navItems.length === 0 || enabledIndices.length === 0) return;
118
+ const focusExpandedParent = () => {
119
+ if (!expandedParent || !navEl) return;
120
+ const idx = navItems.indexOf(expandedParent);
121
+ const parentEls = Array.from(navEl.querySelectorAll<HTMLElement>('[data-nav-item]'));
122
+ parentEls[idx]?.focus();
123
+ };
95
124
 
125
+ const handleKeyDown = (event: KeyboardEvent) => {
126
+ // accordion/expanded は子アイテムも DOM 順で含める
127
+ const includeChildren = subMenuMode === 'accordion' || subMenuMode === 'expanded';
128
+ const selector = includeChildren ? '[data-nav-item], [data-nav-item-child]' : '[data-nav-item]';
96
129
  const navItemEls = Array.from(
97
- (event.currentTarget as HTMLElement).querySelectorAll('[data-nav-item]')
98
- ) as HTMLElement[];
99
- const currentItem = event.target as HTMLElement;
100
- const currentIndex = navItemEls.indexOf(currentItem);
130
+ (event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>(selector)
131
+ ).filter(el => el.tabIndex !== -1);
132
+
133
+ if (navItemEls.length === 0) return;
101
134
 
135
+ const currentIndex = navItemEls.indexOf(event.target as HTMLElement);
102
136
  if (currentIndex === -1) return;
103
137
 
104
- const currentEnabledPosition = enabledIndices.indexOf(currentIndex);
105
- let nextEnabledPosition = currentEnabledPosition;
138
+ // bar モード: 展開中の親で ArrowDown → サブバーの最初の子へ
139
+ if (subMenuMode === 'bar' && event.key === 'ArrowDown' && showSubBar) {
140
+ const allNavItemEls = Array.from(
141
+ (event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[data-nav-item]')
142
+ );
143
+ const expandedEl = allNavItemEls[navItems.indexOf(expandedParent!)];
144
+ if (expandedEl && expandedEl.contains(event.target as Node)) {
145
+ event.preventDefault();
146
+ subBarEl?.querySelector<HTMLElement>('[data-nav-item-child]:not([tabindex="-1"])')?.focus();
147
+ return;
148
+ }
149
+ }
106
150
 
107
151
  const isVertical = variant === 'vertical';
108
152
  const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
@@ -111,28 +155,70 @@
111
155
  switch (event.key) {
112
156
  case prevKey:
113
157
  event.preventDefault();
114
- nextEnabledPosition =
115
- currentEnabledPosition > 0 ? currentEnabledPosition - 1 : enabledIndices.length - 1;
158
+ navItemEls[currentIndex > 0 ? currentIndex - 1 : navItemEls.length - 1]?.focus();
116
159
  break;
117
160
  case nextKey:
118
161
  event.preventDefault();
119
- nextEnabledPosition =
120
- currentEnabledPosition < enabledIndices.length - 1 ? currentEnabledPosition + 1 : 0;
162
+ navItemEls[currentIndex < navItemEls.length - 1 ? currentIndex + 1 : 0]?.focus();
121
163
  break;
122
164
  case 'Home':
123
165
  event.preventDefault();
124
- nextEnabledPosition = 0;
166
+ navItemEls[0]?.focus();
125
167
  break;
126
168
  case 'End':
127
169
  event.preventDefault();
128
- nextEnabledPosition = enabledIndices.length - 1;
170
+ navItemEls[navItemEls.length - 1]?.focus();
129
171
  break;
130
172
  default:
131
173
  return;
132
174
  }
175
+ };
176
+
177
+ const handleSubBarKeyDown = (event: KeyboardEvent) => {
178
+ const container = event.currentTarget as HTMLElement;
179
+ const items = Array.from(
180
+ container.querySelectorAll<HTMLElement>('[data-nav-item-child]:not([tabindex="-1"])')
181
+ );
182
+ const currentIndex = items.indexOf(event.target as HTMLElement);
183
+ if (currentIndex === -1) return;
133
184
 
134
- const nextIndex = enabledIndices[nextEnabledPosition];
135
- navItemEls[nextIndex]?.focus();
185
+ switch (event.key) {
186
+ case 'ArrowLeft':
187
+ event.preventDefault();
188
+ items[currentIndex > 0 ? currentIndex - 1 : items.length - 1]?.focus();
189
+ break;
190
+ case 'ArrowRight':
191
+ event.preventDefault();
192
+ items[currentIndex < items.length - 1 ? currentIndex + 1 : 0]?.focus();
193
+ break;
194
+ case 'ArrowUp':
195
+ event.preventDefault();
196
+ focusExpandedParent();
197
+ break;
198
+ case 'Home':
199
+ event.preventDefault();
200
+ items[0]?.focus();
201
+ break;
202
+ case 'End':
203
+ event.preventDefault();
204
+ items[items.length - 1]?.focus();
205
+ break;
206
+ case 'Escape':
207
+ event.preventDefault();
208
+ focusExpandedParent();
209
+ expandedParent = null;
210
+ break;
211
+ }
212
+ };
213
+
214
+ const handleSubMenuToggle = (item: MenuItem) => {
215
+ if (subMenuMode === 'accordion') {
216
+ // accordion: 開くのみ(再クリックで閉じない、他は自動的に閉じる)
217
+ expandedParent = item;
218
+ } else {
219
+ // bar: トグル
220
+ expandedParent = expandedParent === item ? null : item;
221
+ }
136
222
  };
137
223
 
138
224
  // =========================================================================
@@ -141,33 +227,37 @@
141
227
  const selectedIndex = $derived.by(() => {
142
228
  for (let i = 0; i < navItems.length; i++) {
143
229
  const item = navItems[i];
144
- if (!item.href) continue;
145
- if (matchPath(resolvedCurrentPath, item.href, item, pathPrefix, customPathMatcher)) {
230
+ if (item.href && matchPath(resolvedCurrentPath, item.href, item, pathPrefix, customPathMatcher)) {
231
+ return i;
232
+ }
233
+ // bar モード: 子が選択されていれば親も選択とみなす
234
+ if (subMenuMode === 'bar' && item.children?.some(
235
+ (child) => child.href && matchPath(resolvedCurrentPath, child.href, child, pathPrefix, customPathMatcher)
236
+ )) {
146
237
  return i;
147
238
  }
148
239
  }
149
240
  return -1;
150
241
  });
151
242
 
152
- const enabledIndices = $derived(
153
- navItems.map((item, i) => (item.disabled ? -1 : i)).filter((i) => i >= 0)
243
+ const showSubBar = $derived(
244
+ variant === 'horizontal' && subMenuMode === 'bar' && expandedParent != null
154
245
  );
155
246
 
156
- const isTabVariant = $derived(variant === 'tab');
247
+ const isChildSelected = (child: MenuItem) =>
248
+ !!child.href && matchPath(resolvedCurrentPath, child.href, child, pathPrefix, customPathMatcher);
157
249
  </script>
158
250
 
159
251
  <nav
160
252
  class="nav nav--{variant}"
161
- role={isTabVariant ? 'tablist' : undefined}
162
253
  aria-label={ariaLabelledby ? undefined : ariaLabel}
163
254
  aria-labelledby={ariaLabelledby}
164
- aria-orientation={variant === 'vertical' ? 'vertical' : 'horizontal'}
165
255
  style:--internal-nav-gap={gap != null ? (typeof gap === 'number' ? `${gap}px` : gap) : undefined}
166
- tabindex="-1"
167
256
  {id}
168
- onkeydown={handleKeyDown}
169
257
  data-testid="nav"
258
+ bind:this={navEl}
170
259
  >
260
+ <div style="display: contents" role="presentation" onkeydown={handleKeyDown}>
171
261
  {#each navItems as item, index}
172
262
  <NavItem
173
263
  {item}
@@ -181,32 +271,47 @@
181
271
  {iconOpticalSize}
182
272
  {iconVariant}
183
273
  {selectedStyle}
274
+ {subMenuMode}
275
+ {chevron}
276
+ {resolvedCurrentPath}
277
+ {customPathMatcher}
278
+ isSubMenuExpanded={(subMenuMode === 'bar' || subMenuMode === 'accordion') && expandedParent === item}
279
+ onSubMenuToggle={handleSubMenuToggle}
280
+ onClose={() => { expandedParent = null; }}
184
281
  />
185
282
  {/each}
283
+ </div>
186
284
  </nav>
187
285
 
286
+ <!-- bar モード: 選択中の親の子アイテムを横バーとして表示 -->
287
+ {#if showSubBar && expandedParent?.children}
288
+ <div class="nav__sub-bar" role="menu" tabindex="-1" onkeydown={handleSubBarKeyDown} bind:this={subBarEl}>
289
+ {#each expandedParent.children as child}
290
+ <NavItem
291
+ item={child}
292
+ variant="horizontal"
293
+ {pathPrefix}
294
+ {iconFilled}
295
+ {iconWeight}
296
+ {iconGrade}
297
+ {iconOpticalSize}
298
+ {iconVariant}
299
+ {selectedStyle}
300
+ isChild={true}
301
+ {resolvedCurrentPath}
302
+ {customPathMatcher}
303
+ isSelected={isChildSelected(child)}
304
+ isDisabled={child.disabled ?? false}
305
+ />
306
+ {/each}
307
+ </div>
308
+ {/if}
309
+
188
310
  <style>.nav {
189
311
  display: flex;
190
312
  box-sizing: border-box;
191
313
  }
192
314
 
193
- .nav--tab {
194
- flex-direction: row;
195
- justify-content: start;
196
- position: relative;
197
- width: 100%;
198
- height: 100%;
199
- min-height: var(--svelte-ui-tab-min-height);
200
- overflow-x: auto;
201
- overflow-y: visible;
202
- -ms-overflow-style: none;
203
- overscroll-behavior: contain;
204
- }
205
-
206
- .nav--tab::-webkit-scrollbar {
207
- display: none;
208
- }
209
-
210
315
  .nav--mobile {
211
316
  flex-direction: row;
212
317
  width: 100%;
@@ -221,7 +326,24 @@
221
326
  }
222
327
 
223
328
  .nav--horizontal {
329
+ flex-direction: row;
330
+ justify-content: start;
331
+ align-items: center;
332
+ gap: var(--internal-nav-gap, var(--svelte-ui-nav-horizontal-item-gap));
333
+ overflow-x: auto;
334
+ overflow-y: visible;
335
+ -ms-overflow-style: none;
336
+ overscroll-behavior: none;
337
+ }
338
+
339
+ .nav--horizontal::-webkit-scrollbar {
340
+ display: none;
341
+ }
342
+
343
+ .nav__sub-bar {
344
+ display: flex;
224
345
  flex-direction: row;
225
346
  gap: var(--internal-nav-gap, var(--svelte-ui-nav-horizontal-item-gap));
226
347
  align-items: center;
348
+ min-height: var(--svelte-ui-nav-sub-bar-min-height);
227
349
  }</style>
@@ -1,11 +1,11 @@
1
1
  import type { NavItemSelectedStyle } from './NavItem.svelte';
2
2
  import type { MenuItem } from '../types/menuItem';
3
- import type { NavVariant } from '../types/propOptions';
3
+ import type { NavVariant, SubMenuMode } from '../types/propOptions';
4
4
  import type { IconVariant, IconWeight, IconGrade, IconOpticalSize } from '../types/icon';
5
5
  export type NavProps = {
6
- /** `{ label, href, icon?, disabled? }[]` */
6
+ /** `{ label, href, icon?, children?, disabled? }[]` */
7
7
  navItems?: MenuItem[];
8
- /** Layout variant. @default 'tab' */
8
+ /** Layout variant. @default 'horizontal' */
9
9
  variant?: NavVariant;
10
10
  /** Prepended to each item's href for active-state matching. */
11
11
  pathPrefix?: string;
@@ -22,6 +22,10 @@ export type NavProps = {
22
22
  /** Visual style for the selected item. */
23
23
  selectedStyle?: NavItemSelectedStyle;
24
24
  gap?: number | string;
25
+ /** Sub-menu display mode for items that have children. @default 'popup' */
26
+ subMenuMode?: SubMenuMode;
27
+ /** Show chevron icon on parent items. @default true */
28
+ chevron?: boolean;
25
29
  ariaLabel?: string;
26
30
  ariaLabelledby?: string;
27
31
  };