noph-ui 0.23.3 → 0.24.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.
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import Ripple from '../ripple/Ripple.svelte'
3
- import { onMount } from 'svelte'
4
- import type { TabProps } from './types.ts'
3
+ import { getContext, onMount } from 'svelte'
4
+ import type { TabProps, TabsContext } from './types.ts'
5
5
  import Badge from '../badge/Badge.svelte'
6
6
 
7
7
  let {
@@ -12,66 +12,46 @@
12
12
  onkeydown,
13
13
  value,
14
14
  href,
15
- variant = 'primary',
16
15
  badge = false,
17
16
  badgeLabel,
18
- selected = $bindable(false),
17
+ element = $bindable(),
19
18
  ...attributes
20
19
  }: TabProps = $props()
21
- let element: HTMLElement | undefined = $state()
22
- let id = $props.id()
23
- let parentElement = $derived(element?.parentElement)
24
20
 
25
- const onChange = (event: Event) => {
26
- const { detail } = event as CustomEvent<{ id: string }>
27
- selected = detail.id === id
28
- }
29
-
30
- onMount(() => {
31
- element?.addEventListener('change', onChange)
32
- return () => {
33
- element?.removeEventListener('change', onChange)
34
- }
35
- })
36
-
37
- const setTabActive = (el: HTMLElement) => {
38
- parentElement?.dispatchEvent(new CustomEvent('change', { detail: { id: el.id, value } }))
39
- }
21
+ const tabsContext = getContext<TabsContext>('np-tabs')
22
+ let fallbackIndicator = $state(false)
40
23
 
41
- const onClick = (event: MouseEvent & { currentTarget: EventTarget & HTMLElement }) => {
42
- setTabActive(event.currentTarget)
24
+ const handleClick = (event: MouseEvent & { currentTarget: EventTarget & HTMLElement }) => {
25
+ tabsContext.value = value
43
26
  if (onclick) {
44
27
  onclick(event)
45
28
  }
46
29
  }
47
- const onKeyDown = (event: KeyboardEvent & { currentTarget: EventTarget & HTMLElement }) => {
30
+ const handleKeyDown = (event: KeyboardEvent & { currentTarget: EventTarget & HTMLElement }) => {
48
31
  if (event.key === 'Enter' || event.key === ' ') {
49
- setTabActive(event.currentTarget)
32
+ tabsContext.value = value
50
33
  }
51
34
  if (onkeydown) {
52
35
  onkeydown(event)
53
36
  }
54
37
  }
55
- const setCheckInitialState = () => {
56
- if (parentElement?.getAttribute('data-value') === value) {
57
- selected = true
38
+ onMount(() => {
39
+ if (!('anchorName' in document.documentElement.style)) {
40
+ fallbackIndicator = true
58
41
  }
59
- }
42
+ })
60
43
  </script>
61
44
 
62
45
  {#snippet content()}
63
- <div
64
- class="np-tab-content"
65
- style={variant === 'secondary' ? '--np-indicator-radius: 0;--_indicator-gap: 0' : ''}
66
- >
46
+ <div class={['np-tab-content', tabsContext.value === value && fallbackIndicator && 'fallback']}>
67
47
  <div
68
48
  class={[
69
49
  'np-tab-label',
70
- !inlineIcon && variant === 'primary' && children && icon && 'np-tab-no-inline',
50
+ !inlineIcon && tabsContext.variant === 'primary' && children && icon && 'np-tab-no-inline',
71
51
  ]}
72
52
  >
73
53
  {#if icon}
74
- {#if badge && variant === 'primary' && !inlineIcon}
54
+ {#if badge && tabsContext.variant === 'primary' && !inlineIcon}
75
55
  <div class="np-tab-icon-badge">
76
56
  <Badge label={badgeLabel} />
77
57
  {@render icon?.()}
@@ -80,18 +60,18 @@
80
60
  {@render icon?.()}
81
61
  {/if}
82
62
  {/if}
83
- {#if badge && (!icon || variant === 'secondary' || inlineIcon)}
63
+ {#if badge && (!icon || tabsContext.variant === 'secondary' || inlineIcon)}
84
64
  <div style="--np-badge-position:static;">
85
65
  <span class="np-tab-label-badge">{@render children?.()}</span><Badge label={badgeLabel} />
86
66
  </div>
87
67
  {:else}
88
68
  {@render children?.()}
89
69
  {/if}
90
- {#if variant === 'primary'}
70
+ {#if tabsContext.variant === 'primary'}
91
71
  <div class="np-indicator"></div>
92
72
  {/if}
93
73
  </div>
94
- {#if variant === 'secondary'}
74
+ {#if tabsContext.variant === 'secondary'}
95
75
  <div class="np-indicator"></div>
96
76
  {/if}
97
77
  </div>
@@ -101,56 +81,53 @@
101
81
 
102
82
  {#if href}
103
83
  <a
104
- {@attach setCheckInitialState}
105
84
  {...attributes}
106
- {id}
107
- tabindex={selected ? 0 : -1}
108
- role="tab"
109
- aria-selected={selected}
110
- data-value={value}
111
85
  bind:this={element}
86
+ role="tab"
87
+ aria-selected={tabsContext.value === value}
88
+ tabindex={tabsContext.value === value ? 0 : -1}
112
89
  {href}
113
90
  class={[
114
91
  'np-tab',
115
- selected && 'np-tab-content-active',
116
- variant === 'primary' ? 'primary' : 'secondary',
92
+ tabsContext.value === value && 'np-tab-content-active',
93
+ tabsContext.variant === 'primary' ? 'primary' : 'secondary',
117
94
  attributes.class,
118
95
  ]}
119
- onclick={onClick}
120
- onkeydown={onKeyDown}
96
+ {onclick}
97
+ {onkeydown}
121
98
  >
122
99
  {@render content()}
123
100
  </a>
124
101
  {:else}
125
- <div
126
- {@attach setCheckInitialState}
102
+ <button
127
103
  {...attributes}
128
- {id}
129
- tabindex={selected ? 0 : -1}
130
- role="tab"
131
- aria-selected={selected}
132
- data-value={value}
133
104
  bind:this={element}
105
+ role="tab"
106
+ aria-selected={tabsContext.value === value}
107
+ tabindex={tabsContext.value === value ? 0 : -1}
134
108
  class={[
135
109
  'np-tab',
136
- selected && 'np-tab-content-active',
137
- variant === 'primary' ? 'primary' : 'secondary',
110
+ tabsContext.value === value && 'np-tab-content-active',
111
+ tabsContext.variant === 'primary' ? 'primary' : 'secondary',
138
112
  attributes.class,
139
113
  ]}
140
- onclick={onClick}
141
- onkeydown={onKeyDown}
114
+ onclick={handleClick}
115
+ onkeydown={handleKeyDown}
142
116
  >
143
117
  {@render content()}
144
- </div>
118
+ </button>
145
119
  {/if}
146
120
 
147
121
  <style>
148
122
  .np-tab {
149
123
  flex: 1;
124
+ font-family: inherit;
125
+ background-color: transparent;
126
+ border-width: 0;
127
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
150
128
  display: flex;
151
129
  align-items: center;
152
130
  justify-content: center;
153
- font-family: inherit;
154
131
  box-sizing: border-box;
155
132
  text-decoration: none;
156
133
  position: relative;
@@ -158,7 +135,7 @@
158
135
  padding: 0 1rem;
159
136
  color: var(--np-color-on-surface-variant);
160
137
  height: 3rem;
161
- transition: color 0.3s ease;
138
+ transition: color 0.3s cubic-bezier(0.33, 1, 0.68, 1);
162
139
  }
163
140
  .np-tab-content-active {
164
141
  --_focus-bottom: 4px;
@@ -210,24 +187,12 @@
210
187
  left: var(--_indicator-gap, 2px);
211
188
  right: var(--_indicator-gap, 2px);
212
189
  height: 3px;
190
+ }
191
+
192
+ .fallback .np-indicator {
213
193
  background-color: var(--np-color-primary);
214
194
  border-top-left-radius: var(--np-indicator-radius, var(--np-shape-corner-full));
215
195
  border-top-right-radius: var(--np-indicator-radius, var(--np-shape-corner-full));
216
- opacity: 0;
217
- }
218
- .np-tab-content-active .np-indicator {
219
- opacity: 1;
220
- transform-origin: left center;
221
- animation: slide 0.3s ease-in-out;
222
- }
223
-
224
- @keyframes slide {
225
- 0% {
226
- transform: translateX(var(--np-tab-indicator-start)) scaleX(var(--np-tab-indicator-scale, 1));
227
- }
228
- 100% {
229
- transform: translateX(0) scaleX(1);
230
- }
231
196
  }
232
197
 
233
198
  .focus-area {
@@ -1,4 +1,4 @@
1
1
  import type { TabProps } from './types.ts';
2
- declare const Tab: import("svelte").Component<TabProps, {}, "selected">;
2
+ declare const Tab: import("svelte").Component<TabProps, {}, "element">;
3
3
  type Tab = ReturnType<typeof Tab>;
4
4
  export default Tab;
@@ -1,115 +1,74 @@
1
1
  <script lang="ts">
2
2
  import Divider from '../divider/Divider.svelte'
3
- import { onMount } from 'svelte'
4
- import type { TabsProps } from './types.ts'
3
+ import { setContext } from 'svelte'
4
+ import type { TabsContext, TabsProps } from './types.ts'
5
5
 
6
- let { children, element = $bindable(), value = $bindable(), ...attributes }: TabsProps = $props()
7
- const initialValue = value
6
+ let {
7
+ children,
8
+ element = $bindable(),
9
+ value = $bindable(),
10
+ variant = 'primary',
11
+ ...attributes
12
+ }: TabsProps = $props()
8
13
 
9
- const getCurrentTabs = () => {
10
- if (!element) {
11
- return []
12
- }
13
- return Array.from(element.querySelectorAll<HTMLElement>('.np-tab'))
14
- }
14
+ let uid = $props.id()
15
+ let tabsContext = $state<TabsContext>({
16
+ value,
17
+ variant,
18
+ id: uid,
19
+ })
20
+ $effect(() => {
21
+ value = tabsContext.value
22
+ })
15
23
 
16
24
  $effect(() => {
17
- if (value) {
18
- const tabs = getCurrentTabs()
19
- const newTab = tabs?.find((tab) => tab.getAttribute('data-value') === value)
20
- if (newTab) {
21
- selectTab(newTab, tabs, { id: newTab.id, value })
22
- }
23
- }
25
+ tabsContext.value = value
26
+ tabsContext.variant = variant
24
27
  })
28
+ setContext('np-tabs', tabsContext)
25
29
 
26
- const selectTab = (
27
- newTab: HTMLElement,
28
- tabs: HTMLElement[],
29
- detail: { id: string; value: string | number },
30
+ const handleKeydown = (
31
+ event: KeyboardEvent & {
32
+ currentTarget: EventTarget & HTMLDivElement
33
+ },
30
34
  ) => {
31
- const oldTab = tabs.find((tab) => tab.getAttribute('aria-selected') === 'true')
32
- if (!oldTab || oldTab === newTab) {
33
- return
34
- }
35
- const oldIndicator = oldTab.querySelector<HTMLElement>('.np-indicator')
36
- const oldIndicatorRect = oldIndicator?.getBoundingClientRect()
37
- if (oldIndicatorRect) {
38
- const newIndicator = newTab.querySelector<HTMLElement>('.np-indicator')
39
- if (newIndicator) {
40
- newIndicator.style.setProperty(
41
- '--np-tab-indicator-start',
42
- `${oldIndicatorRect.x - newIndicator.getBoundingClientRect().x}px`,
43
- )
44
- newIndicator.style.setProperty(
45
- '--np-tab-indicator-scale',
46
- `${oldIndicatorRect.width / newIndicator.clientWidth}`,
47
- )
48
- }
49
- }
50
- value = detail.value
51
- tabs?.forEach((tab) => {
52
- tab.dispatchEvent(new CustomEvent('change', { detail }))
53
- })
54
- }
55
-
56
- const onChange = (event: Event) => {
57
- const { detail } = event as CustomEvent<{ id: string; value: string | number }>
58
- const tabs = getCurrentTabs()
59
- const newTab = tabs?.find((tab) => tab.id === detail.id)
60
- if (newTab) {
61
- selectTab(newTab, tabs, detail)
62
- }
63
- }
64
-
65
- onMount(() => {
66
- element?.addEventListener('change', onChange)
67
- return () => {
68
- element?.removeEventListener('change', onChange)
69
- }
70
- })
71
- const initialSetup = (el: HTMLElement) => {
72
- const tabs = Array.from(el.querySelectorAll<HTMLElement>('.np-tab'))
73
- const activeTab =
74
- tabs && tabs.length > 0
75
- ? (tabs.find((t) => {
76
- return t.getAttribute('data-value') === initialValue
77
- }) ?? tabs[0])
78
- : undefined
79
- if (initialValue === undefined) {
80
- value = activeTab?.getAttribute('data-value') ?? undefined
81
- } else {
82
- value = initialValue
35
+ const tabs = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('.np-tab'))
36
+ if (tabs && tabs.length > 0 && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
37
+ const focusedTab = event.currentTarget.querySelector<HTMLElement>('.np-tab:focus')
38
+ const currentIndex = focusedTab ? tabs.indexOf(focusedTab) : 0
39
+ const index = currentIndex + (event.key === 'ArrowRight' ? 1 : -1)
40
+ const newTab =
41
+ index < 0 ? tabs[tabs.length - 1] : index >= tabs.length ? tabs[0] : tabs[index]
42
+ newTab.focus()
43
+ event.preventDefault()
83
44
  }
84
45
  }
85
46
  </script>
86
47
 
87
- <div {@attach initialSetup} {...attributes} class={[attributes.class]}>
48
+ <nav
49
+ {...attributes}
50
+ bind:this={element}
51
+ style={tabsContext.variant === 'secondary' ? '--np-indicator-radius: 0;--_indicator-gap: 0' : ''}
52
+ >
88
53
  <div
89
54
  class={['np-tabs']}
90
55
  role="tablist"
91
- data-value={value}
56
+ aria-orientation="horizontal"
92
57
  tabindex="-1"
93
- bind:this={element}
94
- onkeydown={(event) => {
95
- const tabs = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('.np-tab'))
96
- if (tabs && tabs.length > 0 && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
97
- const focusedTab = event.currentTarget.querySelector<HTMLElement>('.np-tab:focus')
98
- const currentIndex = focusedTab ? tabs.indexOf(focusedTab) : 0
99
- const index = currentIndex + (event.key === 'ArrowRight' ? 1 : -1)
100
- const newTab =
101
- index < 0 ? tabs[tabs.length - 1] : index >= tabs.length ? tabs[0] : tabs[index]
102
- newTab.focus()
103
- }
104
- }}
58
+ onkeydown={handleKeydown}
105
59
  >
106
60
  {@render children?.()}
107
61
  </div>
108
62
  <Divider />
109
- </div>
63
+ </nav>
110
64
 
111
65
  <style>
66
+ :global(.np-tabs .np-tab-content-active .np-indicator) {
67
+ anchor-name: --np-tab-indicator;
68
+ }
112
69
  .np-tabs {
70
+ padding: 0;
71
+ margin: 0;
113
72
  display: flex;
114
73
  align-items: end;
115
74
  width: 100%;
@@ -118,5 +77,20 @@
118
77
  scroll-behavior: smooth;
119
78
  overflow: auto;
120
79
  background-color: var(--np-color-surface);
80
+ position: relative;
81
+
82
+ &::after {
83
+ content: '';
84
+ position: absolute;
85
+ height: 3px;
86
+ left: anchor(left);
87
+ right: anchor(right);
88
+ bottom: anchor(bottom);
89
+ background-color: var(--np-color-primary);
90
+ border-top-left-radius: var(--np-indicator-radius, var(--np-shape-corner-full));
91
+ border-top-right-radius: var(--np-indicator-radius, var(--np-shape-corner-full));
92
+ position-anchor: --np-tab-indicator;
93
+ transition: cubic-bezier(0.33, 1, 0.68, 1) 0.3s;
94
+ }
121
95
  }
122
96
  </style>
@@ -1,16 +1,21 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  export interface TabProps extends HTMLAttributes<HTMLElement> {
4
- variant?: 'primary' | 'secondary';
5
4
  inlineIcon?: boolean;
6
5
  value: string | number;
7
6
  href?: string;
8
7
  icon?: Snippet;
9
8
  badge?: boolean;
10
9
  badgeLabel?: string | number;
11
- selected?: boolean;
10
+ element?: HTMLElement;
12
11
  }
13
- export interface TabsProps extends HTMLAttributes<HTMLDivElement> {
14
- value?: string | number;
12
+ export interface TabsProps extends HTMLAttributes<HTMLElement> {
13
+ variant?: 'primary' | 'secondary';
14
+ value: string | number;
15
15
  element?: HTMLElement;
16
16
  }
17
+ export interface TabsContext {
18
+ value: string | number;
19
+ variant: 'primary' | 'secondary';
20
+ id: string;
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noph-ui",
3
- "version": "0.23.3",
3
+ "version": "0.24.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {
@@ -56,11 +56,11 @@
56
56
  "devDependencies": {
57
57
  "@eslint/js": "^9.33.0",
58
58
  "@material/material-color-utilities": "^0.3.0",
59
- "@playwright/test": "^1.54.2",
60
- "@sveltejs/adapter-vercel": "^5.9.1",
61
- "@sveltejs/kit": "^2.31.1",
62
- "@sveltejs/package": "^2.4.1",
63
- "@sveltejs/vite-plugin-svelte": "^6.1.2",
59
+ "@playwright/test": "^1.55.0",
60
+ "@sveltejs/adapter-vercel": "^5.10.2",
61
+ "@sveltejs/kit": "^2.36.1",
62
+ "@sveltejs/package": "^2.5.0",
63
+ "@sveltejs/vite-plugin-svelte": "^6.1.3",
64
64
  "@types/eslint": "^9.6.1",
65
65
  "eslint": "^9.33.0",
66
66
  "eslint-config-prettier": "^10.1.8",
@@ -69,11 +69,11 @@
69
69
  "prettier": "^3.6.2",
70
70
  "prettier-plugin-svelte": "^3.4.0",
71
71
  "publint": "^0.3.12",
72
- "svelte": "^5.38.1",
72
+ "svelte": "^5.38.2",
73
73
  "svelte-check": "^4.3.1",
74
74
  "typescript": "^5.9.2",
75
- "typescript-eslint": "^8.39.1",
76
- "vite": "^7.1.2",
75
+ "typescript-eslint": "^8.40.0",
76
+ "vite": "^7.1.3",
77
77
  "vitest": "^3.2.4"
78
78
  },
79
79
  "svelte": "./dist/index.js",