create-nativecore 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -18
- package/bin/index.mjs +407 -489
- package/package.json +4 -3
- package/template/.env.example +28 -0
- package/template/.htmlhintrc +14 -0
- package/template/api/data/dashboard.json +11 -0
- package/template/api/data/users.json +18 -0
- package/template/api/mockApi.js +161 -0
- package/template/assets/icon.svg +13 -0
- package/template/assets/logo.svg +25 -0
- package/template/eslint.config.js +94 -0
- package/template/index.html +137 -0
- package/template/manifest.json +19 -0
- package/template/public/.well-known/security.txt +9 -0
- package/template/public/_headers +24 -0
- package/template/public/_redirects +14 -0
- package/template/public/assets/icon.svg +13 -0
- package/template/public/assets/logo.svg +25 -0
- package/template/public/manifest.json +19 -0
- package/template/public/robots.txt +13 -0
- package/template/public/sitemap.xml +27 -0
- package/template/scripts/build-for-bots.mjs +121 -0
- package/template/scripts/convert-to-ts.mjs +106 -0
- package/template/scripts/fix-encoding.mjs +38 -0
- package/template/scripts/fix-svg-paths.mjs +32 -0
- package/template/scripts/generate-cf-router.mjs +52 -0
- package/template/scripts/inject-dev-tools.mjs +41 -0
- package/template/scripts/inject-version.mjs +65 -0
- package/template/scripts/make-component.mjs +445 -0
- package/template/scripts/make-component.mjs.backup +432 -0
- package/template/scripts/make-controller.mjs +119 -0
- package/template/scripts/make-core-component.mjs +303 -0
- package/template/scripts/make-view.mjs +346 -0
- package/template/scripts/minify.mjs +71 -0
- package/template/scripts/prepare-static-assets.mjs +141 -0
- package/template/scripts/prompt-bot-build.mjs +223 -0
- package/template/scripts/remove-component.mjs +170 -0
- package/template/scripts/remove-core-component.mjs +156 -0
- package/template/scripts/remove-dev.mjs +13 -0
- package/template/scripts/remove-view.mjs +200 -0
- package/template/scripts/strip-dev-blocks.mjs +30 -0
- package/template/scripts/watch-compile.mjs +69 -0
- package/template/server.js +1066 -0
- package/template/src/app.ts +115 -0
- package/template/src/components/appRegistry.ts +8 -0
- package/template/src/components/core/app-footer.ts +27 -0
- package/template/src/components/core/app-header.ts +175 -0
- package/template/src/components/core/app-sidebar.ts +238 -0
- package/template/src/components/core/loading-spinner.ts +25 -0
- package/template/src/components/core/nc-a.ts +313 -0
- package/template/src/components/core/nc-accordion.ts +186 -0
- package/template/src/components/core/nc-alert.ts +153 -0
- package/template/src/components/core/nc-animation.ts +1150 -0
- package/template/src/components/core/nc-autocomplete.ts +271 -0
- package/template/src/components/core/nc-avatar-group.ts +113 -0
- package/template/src/components/core/nc-avatar.ts +148 -0
- package/template/src/components/core/nc-badge.ts +86 -0
- package/template/src/components/core/nc-bottom-nav.ts +214 -0
- package/template/src/components/core/nc-breadcrumb.ts +96 -0
- package/template/src/components/core/nc-button.ts +307 -0
- package/template/src/components/core/nc-card.ts +160 -0
- package/template/src/components/core/nc-checkbox.ts +282 -0
- package/template/src/components/core/nc-chip.ts +115 -0
- package/template/src/components/core/nc-code.ts +314 -0
- package/template/src/components/core/nc-collapsible.ts +154 -0
- package/template/src/components/core/nc-color-picker.ts +268 -0
- package/template/src/components/core/nc-copy-button.ts +119 -0
- package/template/src/components/core/nc-date-picker.ts +443 -0
- package/template/src/components/core/nc-div.ts +280 -0
- package/template/src/components/core/nc-divider.ts +81 -0
- package/template/src/components/core/nc-drawer.ts +230 -0
- package/template/src/components/core/nc-dropdown.ts +178 -0
- package/template/src/components/core/nc-empty-state.ts +134 -0
- package/template/src/components/core/nc-file-upload.ts +354 -0
- package/template/src/components/core/nc-form.ts +312 -0
- package/template/src/components/core/nc-image.ts +184 -0
- package/template/src/components/core/nc-input.ts +383 -0
- package/template/src/components/core/nc-kbd.ts +48 -0
- package/template/src/components/core/nc-menu-item.ts +193 -0
- package/template/src/components/core/nc-menu.ts +376 -0
- package/template/src/components/core/nc-modal.ts +238 -0
- package/template/src/components/core/nc-nav-item.ts +151 -0
- package/template/src/components/core/nc-number-input.ts +350 -0
- package/template/src/components/core/nc-otp-input.ts +235 -0
- package/template/src/components/core/nc-pagination.ts +178 -0
- package/template/src/components/core/nc-popover.ts +260 -0
- package/template/src/components/core/nc-progress-circular.ts +119 -0
- package/template/src/components/core/nc-progress.ts +134 -0
- package/template/src/components/core/nc-radio.ts +235 -0
- package/template/src/components/core/nc-rating.ts +266 -0
- package/template/src/components/core/nc-rich-text.ts +283 -0
- package/template/src/components/core/nc-scroll-top.ts +116 -0
- package/template/src/components/core/nc-select.ts +452 -0
- package/template/src/components/core/nc-skeleton.ts +107 -0
- package/template/src/components/core/nc-slider.ts +285 -0
- package/template/src/components/core/nc-snackbar.ts +230 -0
- package/template/src/components/core/nc-splash.ts +343 -0
- package/template/src/components/core/nc-stepper.ts +247 -0
- package/template/src/components/core/nc-switch.ts +281 -0
- package/template/src/components/core/nc-tab-item.ts +138 -0
- package/template/src/components/core/nc-table.ts +279 -0
- package/template/src/components/core/nc-tabs.ts +554 -0
- package/template/src/components/core/nc-tag-input.ts +279 -0
- package/template/src/components/core/nc-textarea.ts +216 -0
- package/template/src/components/core/nc-time-picker.ts +438 -0
- package/template/src/components/core/nc-timeline.ts +186 -0
- package/template/src/components/core/nc-tooltip.ts +143 -0
- package/template/src/components/frameworkRegistry.ts +68 -0
- package/template/src/components/preloadRegistry.ts +28 -0
- package/template/src/components/registry.ts +8 -0
- package/template/src/components/ui/dashboard-signal-lab.ts +284 -0
- package/template/src/constants/apiEndpoints.ts +27 -0
- package/template/src/constants/errorMessages.ts +23 -0
- package/template/src/constants/index.ts +8 -0
- package/template/src/constants/routePaths.ts +15 -0
- package/template/src/constants/storageKeys.ts +18 -0
- package/template/src/controllers/dashboard.controller.ts +200 -0
- package/template/src/controllers/home.controller.ts +21 -0
- package/template/src/controllers/index.ts +11 -0
- package/template/src/controllers/login.controller.ts +131 -0
- package/template/src/core/component.ts +354 -0
- package/template/src/core/errorHandler.ts +85 -0
- package/template/src/core/gpu-animation.ts +604 -0
- package/template/src/core/http.ts +173 -0
- package/template/src/core/lazyComponents.ts +90 -0
- package/template/src/core/router.ts +642 -0
- package/template/src/core/signals.ts +146 -0
- package/template/src/core/state.ts +248 -0
- package/template/src/dev/component-editor.ts +1363 -0
- package/template/src/dev/component-overlay.ts +278 -0
- package/template/src/dev/context-menu.ts +223 -0
- package/template/src/dev/denc-tools.ts +250 -0
- package/template/src/dev/hmr.ts +189 -0
- package/template/src/dev/nfbs.code-workspace +27 -0
- package/template/src/dev/outline-panel.ts +1247 -0
- package/template/src/middleware/auth.middleware.ts +23 -0
- package/template/src/routes/routes.ts +38 -0
- package/template/src/services/api.service.ts +394 -0
- package/template/src/services/auth.service.ts +176 -0
- package/template/src/services/index.ts +8 -0
- package/template/src/services/logger.service.ts +74 -0
- package/template/src/services/storage.service.ts +88 -0
- package/template/src/stores/appStore.ts +57 -0
- package/template/src/stores/uiStore.ts +36 -0
- package/template/src/styles/core-variables.css +219 -0
- package/template/src/styles/core.css +710 -0
- package/template/src/styles/main.css +3164 -0
- package/template/src/styles/variables.css +152 -0
- package/template/src/types/global.d.ts +47 -0
- package/template/src/utils/cacheBuster.ts +20 -0
- package/template/src/utils/dom.ts +149 -0
- package/template/src/utils/events.ts +203 -0
- package/template/src/utils/form.ts +176 -0
- package/template/src/utils/formatters.ts +169 -0
- package/template/src/utils/helpers.ts +195 -0
- package/template/src/utils/markdown.ts +307 -0
- package/template/src/utils/sidebar.ts +96 -0
- package/template/src/utils/smoothScroll.ts +85 -0
- package/template/src/utils/templates.ts +23 -0
- package/template/src/utils/validation.ts +73 -0
- package/template/src/views/protected/dashboard.html +293 -0
- package/template/src/views/public/home.html +150 -0
- package/template/src/views/public/login.html +102 -0
- package/template/tests/unit/component.test.ts +87 -0
- package/template/tests/unit/computed.test.ts +79 -0
- package/template/tests/unit/form.test.ts +68 -0
- package/template/tests/unit/formatters.test.ts +49 -0
- package/template/tests/unit/lazy-components.test.ts +59 -0
- package/template/tests/unit/markdown.test.ts +62 -0
- package/template/tests/unit/router.test.ts +112 -0
- package/template/tests/unit/signals.test.ts +54 -0
- package/template/tests/unit/validation.test.ts +50 -0
- package/template/tsconfig.build.json +21 -0
- package/template/tsconfig.json +51 -0
- package/template/vitest.config.ts +36 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NativeCore Tabs Component (nc-tabs)
|
|
3
|
+
*
|
|
4
|
+
* Container that manages a group of nc-tab-item panels. Renders the tab bar,
|
|
5
|
+
* handles selection, keyboard navigation, and emits change events.
|
|
6
|
+
*
|
|
7
|
+
* Attributes:
|
|
8
|
+
* variant — 'line' | 'pills' | 'boxed' (default: 'line')
|
|
9
|
+
* active — Zero-based index of the currently active tab (default: 0).
|
|
10
|
+
* Can be set externally to programmatically switch tabs.
|
|
11
|
+
* persist — Boolean. When present, persist the selected tab in sessionStorage.
|
|
12
|
+
* Uses `persist-key` if provided, else falls back to path + element id.
|
|
13
|
+
* persist-key — Optional storage key override for tab persistence.
|
|
14
|
+
* transition — Panel enter animation.
|
|
15
|
+
* 'fade' | 'slide-up' | 'slide-down' | 'slide-right' | 'slide-left' | 'none' (default: 'fade')
|
|
16
|
+
*
|
|
17
|
+
* Slots:
|
|
18
|
+
* default — Place nc-tab-item elements here.
|
|
19
|
+
*
|
|
20
|
+
* Events emitted:
|
|
21
|
+
* nc-tab-change — { index: number, label: string | null }
|
|
22
|
+
* Fires whenever the active tab changes.
|
|
23
|
+
*
|
|
24
|
+
* Keyboard support:
|
|
25
|
+
* ArrowRight / ArrowLeft — next / previous tab
|
|
26
|
+
* Home / End — first / last tab
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* <nc-tabs variant="line" active="0">
|
|
30
|
+
* <nc-tab-item label="Overview">Overview content</nc-tab-item>
|
|
31
|
+
* <nc-tab-item label="Activity">Activity content</nc-tab-item>
|
|
32
|
+
* <nc-tab-item label="Settings" disabled>Settings</nc-tab-item>
|
|
33
|
+
* </nc-tabs>
|
|
34
|
+
*
|
|
35
|
+
* // Programmatic control
|
|
36
|
+
* document.querySelector('nc-tabs').setAttribute('active', '1');
|
|
37
|
+
*
|
|
38
|
+
* // Listen for changes
|
|
39
|
+
* document.querySelector('nc-tabs').addEventListener('nc-tab-change', e => {
|
|
40
|
+
* console.log(e.detail.index, e.detail.label);
|
|
41
|
+
* });
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { Component, defineComponent } from '@core/component.js';
|
|
45
|
+
import { html } from '@utils/templates.js';
|
|
46
|
+
|
|
47
|
+
export class NcTabs extends Component {
|
|
48
|
+
static useShadowDOM = true;
|
|
49
|
+
|
|
50
|
+
static attributeOptions = {
|
|
51
|
+
variant: ['line', 'pills', 'boxed'],
|
|
52
|
+
transition: ['fade', 'slide-up', 'slide-down', 'slide-right', 'slide-left', 'none'],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
static get observedAttributes() {
|
|
56
|
+
return ['variant', 'active', 'transition', 'persist', 'persist-key'];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── internal state ────────────────────────────────────────────────────────
|
|
60
|
+
private _activeIndex: number = 0;
|
|
61
|
+
|
|
62
|
+
// ─── cleanup refs ──────────────────────────────────────────────────────────
|
|
63
|
+
private _onSlotChange: (() => void) | null = null;
|
|
64
|
+
private _onShadowClick: ((e: Event) => void) | null = null;
|
|
65
|
+
private _onShadowKeydown: ((e: Event) => void) | null = null;
|
|
66
|
+
|
|
67
|
+
// ─── scroll arrow state ────────────────────────────────────────────────────
|
|
68
|
+
private _scrollListenerSet = false;
|
|
69
|
+
private _resizeObserver: ResizeObserver | null = null;
|
|
70
|
+
|
|
71
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
template(): string {
|
|
74
|
+
const variant = this.attr('variant', 'line');
|
|
75
|
+
|
|
76
|
+
return html`
|
|
77
|
+
<style>
|
|
78
|
+
/* ── Host layout ─────────────────────────────────────────────── */
|
|
79
|
+
:host {
|
|
80
|
+
display: block;
|
|
81
|
+
width: 100%;
|
|
82
|
+
font-family: var(--nc-font-family);
|
|
83
|
+
box-sizing: border-box;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.nc-tabs {
|
|
87
|
+
width: 100%;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ── Tab bar ─────────────────────────────────────────────────── */
|
|
91
|
+
.nc-tabs__bar {
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: flex-end;
|
|
94
|
+
position: relative;
|
|
95
|
+
/* line variant default: bottom border */
|
|
96
|
+
border-bottom: 2px solid var(--nc-border);
|
|
97
|
+
gap: 0;
|
|
98
|
+
/* — critical for scroll: width must be bounded, not grow to content — */
|
|
99
|
+
width: 100%;
|
|
100
|
+
overflow-x: auto;
|
|
101
|
+
scrollbar-width: none;
|
|
102
|
+
-webkit-overflow-scrolling: touch;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.nc-tabs__bar::-webkit-scrollbar {
|
|
106
|
+
display: none;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ── Tab buttons ─────────────────────────────────────────────── */
|
|
110
|
+
.nc-tabs__btn {
|
|
111
|
+
display: inline-flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
justify-content: center;
|
|
114
|
+
gap: var(--nc-spacing-xs);
|
|
115
|
+
background: transparent;
|
|
116
|
+
border: none;
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
font-family: inherit;
|
|
119
|
+
font-size: var(--nc-font-size-sm);
|
|
120
|
+
font-weight: var(--nc-font-weight-medium);
|
|
121
|
+
color: var(--nc-text-secondary);
|
|
122
|
+
padding: var(--nc-spacing-sm) var(--nc-spacing-md);
|
|
123
|
+
position: relative;
|
|
124
|
+
margin-bottom: -2px;
|
|
125
|
+
white-space: nowrap;
|
|
126
|
+
outline: none;
|
|
127
|
+
transition:
|
|
128
|
+
color var(--nc-transition-fast),
|
|
129
|
+
background var(--nc-transition-fast);
|
|
130
|
+
border-radius: var(--nc-radius-sm) var(--nc-radius-sm) 0 0;
|
|
131
|
+
user-select: none;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Sliding underline indicator (line variant) */
|
|
135
|
+
.nc-tabs__btn::after {
|
|
136
|
+
content: '';
|
|
137
|
+
position: absolute;
|
|
138
|
+
bottom: 0;
|
|
139
|
+
left: 0;
|
|
140
|
+
right: 0;
|
|
141
|
+
height: 2px;
|
|
142
|
+
background: var(--nc-primary);
|
|
143
|
+
border-radius: 2px 2px 0 0;
|
|
144
|
+
transform: scaleX(0);
|
|
145
|
+
transition: transform var(--nc-transition-fast) var(--nc-ease-out);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.nc-tabs__btn:hover:not([disabled]) {
|
|
149
|
+
color: var(--nc-text);
|
|
150
|
+
background: rgba(0, 0, 0, 0.03);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.nc-tabs__btn--active {
|
|
154
|
+
color: var(--nc-primary);
|
|
155
|
+
font-weight: var(--nc-font-weight-semibold);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.nc-tabs__btn--active::after {
|
|
159
|
+
transform: scaleX(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.nc-tabs__btn--disabled {
|
|
163
|
+
opacity: 0.4;
|
|
164
|
+
cursor: not-allowed;
|
|
165
|
+
pointer-events: none;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.nc-tabs__btn:focus-visible {
|
|
169
|
+
outline: 2px solid var(--nc-primary);
|
|
170
|
+
outline-offset: -2px;
|
|
171
|
+
border-radius: var(--nc-radius-sm);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ── Pills variant ───────────────────────────────────────────── */
|
|
175
|
+
:host([variant="pills"]) .nc-tabs__bar {
|
|
176
|
+
border-bottom: none;
|
|
177
|
+
gap: var(--nc-spacing-xs);
|
|
178
|
+
padding: var(--nc-spacing-xs);
|
|
179
|
+
background: var(--nc-bg-secondary);
|
|
180
|
+
border-radius: var(--nc-radius-lg);
|
|
181
|
+
/* width: 100% inherited from base — pills scrolls rather than spills */
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
:host([variant="pills"]) .nc-tabs__btn {
|
|
185
|
+
border-radius: var(--nc-radius-md);
|
|
186
|
+
margin-bottom: 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
:host([variant="pills"]) .nc-tabs__btn::after {
|
|
190
|
+
display: none;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
:host([variant="pills"]) .nc-tabs__btn--active {
|
|
194
|
+
background: var(--nc-primary);
|
|
195
|
+
color: var(--nc-white);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
:host([variant="pills"]) .nc-tabs__btn:hover:not([disabled]):not(.nc-tabs__btn--active) {
|
|
199
|
+
background: var(--nc-bg-tertiary);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ── Boxed variant ───────────────────────────────────────────── */
|
|
203
|
+
:host([variant="boxed"]) .nc-tabs__bar {
|
|
204
|
+
border-bottom: 1px solid var(--nc-border);
|
|
205
|
+
gap: 2px;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
:host([variant="boxed"]) .nc-tabs__btn {
|
|
209
|
+
border: 1px solid transparent;
|
|
210
|
+
border-bottom: none;
|
|
211
|
+
border-radius: var(--nc-radius-md) var(--nc-radius-md) 0 0;
|
|
212
|
+
margin-bottom: -1px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
:host([variant="boxed"]) .nc-tabs__btn::after {
|
|
216
|
+
display: none;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
:host([variant="boxed"]) .nc-tabs__btn--active {
|
|
220
|
+
border-color: var(--nc-border);
|
|
221
|
+
background: var(--nc-bg);
|
|
222
|
+
color: var(--nc-primary);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ── Content panels ──────────────────────────────────────────── */
|
|
226
|
+
.nc-tabs__panels {
|
|
227
|
+
padding-top: var(--nc-spacing-sm);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ── Bar wrapper (anchors scroll arrows) ─────────────────────── */
|
|
231
|
+
.nc-tabs__bar-wrap {
|
|
232
|
+
position: relative;
|
|
233
|
+
/* contain the bar so overflow-x:auto has a definite boundary */
|
|
234
|
+
min-width: 0;
|
|
235
|
+
overflow: hidden;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ── Scroll arrow buttons ────────────────────────────────────── */
|
|
239
|
+
.nc-tabs__scroll-btn {
|
|
240
|
+
display: none; /* JS sets to flex when scrollable */
|
|
241
|
+
position: absolute;
|
|
242
|
+
top: 0;
|
|
243
|
+
bottom: 0;
|
|
244
|
+
width: 36px;
|
|
245
|
+
align-items: center;
|
|
246
|
+
justify-content: center;
|
|
247
|
+
border: none;
|
|
248
|
+
padding: 0;
|
|
249
|
+
cursor: pointer;
|
|
250
|
+
z-index: 2;
|
|
251
|
+
color: var(--nc-text-muted);
|
|
252
|
+
transition: color var(--nc-transition-fast);
|
|
253
|
+
}
|
|
254
|
+
.nc-tabs__scroll-btn:hover { color: var(--nc-text); }
|
|
255
|
+
|
|
256
|
+
.nc-tabs__scroll-btn--prev {
|
|
257
|
+
left: 0;
|
|
258
|
+
background: linear-gradient(to right, var(--nc-bg) 45%, transparent 100%);
|
|
259
|
+
}
|
|
260
|
+
.nc-tabs__scroll-btn--next {
|
|
261
|
+
right: 0;
|
|
262
|
+
background: linear-gradient(to left, var(--nc-bg) 45%, transparent 100%);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* ── Responsive ──────────────────────────────────────────────── */
|
|
266
|
+
@media (max-width: 640px) {
|
|
267
|
+
.nc-tabs__panels {
|
|
268
|
+
padding-top: 0;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
</style>
|
|
272
|
+
|
|
273
|
+
<div class="nc-tabs nc-tabs--${variant}">
|
|
274
|
+
<div class="nc-tabs__bar-wrap">
|
|
275
|
+
<button class="nc-tabs__scroll-btn nc-tabs__scroll-btn--prev" type="button" aria-label="Scroll tabs back" aria-hidden="true">
|
|
276
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none" width="10" height="10"><path d="M8 2L4 6l4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
277
|
+
</button>
|
|
278
|
+
<div class="nc-tabs__bar" role="tablist" aria-label="Tabs"></div>
|
|
279
|
+
<button class="nc-tabs__scroll-btn nc-tabs__scroll-btn--next" type="button" aria-label="Scroll tabs forward" aria-hidden="true">
|
|
280
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none" width="10" height="10"><path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
281
|
+
</button>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="nc-tabs__panels">
|
|
284
|
+
<slot></slot>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
onMount(): void {
|
|
291
|
+
this._activeIndex = parseInt(this.attr('active', '0') ?? '0', 10);
|
|
292
|
+
|
|
293
|
+
const persistedIndex = this._readPersistedIndex();
|
|
294
|
+
if (persistedIndex !== null) {
|
|
295
|
+
this._activeIndex = persistedIndex;
|
|
296
|
+
this.setAttribute('active', String(persistedIndex));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Defer first build one tick — child nc-tab-item elements upgrade after parent
|
|
300
|
+
Promise.resolve().then(() => this._buildTabBar());
|
|
301
|
+
|
|
302
|
+
// Re-build when tab items are added or removed dynamically
|
|
303
|
+
const slot = this.$<HTMLSlotElement>('slot');
|
|
304
|
+
if (slot) {
|
|
305
|
+
this._onSlotChange = () => this._buildTabBar();
|
|
306
|
+
slot.addEventListener('slotchange', this._onSlotChange);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Click delegation on shadow root — handles all tab button clicks
|
|
310
|
+
this._onShadowClick = (e: Event) => {
|
|
311
|
+
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-tab-index]');
|
|
312
|
+
if (!btn || btn.hasAttribute('disabled')) return;
|
|
313
|
+
const index = parseInt(btn.dataset.tabIndex!, 10);
|
|
314
|
+
if (!isNaN(index)) this._selectTab(index);
|
|
315
|
+
};
|
|
316
|
+
this.shadowRoot!.addEventListener('click', this._onShadowClick);
|
|
317
|
+
|
|
318
|
+
// Keyboard navigation on the tab bar
|
|
319
|
+
this._onShadowKeydown = (e: Event) => {
|
|
320
|
+
const ke = e as KeyboardEvent;
|
|
321
|
+
if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(ke.key)) return;
|
|
322
|
+
ke.preventDefault();
|
|
323
|
+
|
|
324
|
+
const tabs = this._getTabItems();
|
|
325
|
+
const len = tabs.length;
|
|
326
|
+
if (len === 0) return;
|
|
327
|
+
|
|
328
|
+
let next = this._activeIndex;
|
|
329
|
+
|
|
330
|
+
if (ke.key === 'ArrowRight') {
|
|
331
|
+
next = (this._activeIndex + 1) % len;
|
|
332
|
+
} else if (ke.key === 'ArrowLeft') {
|
|
333
|
+
next = (this._activeIndex - 1 + len) % len;
|
|
334
|
+
} else if (ke.key === 'Home') {
|
|
335
|
+
next = 0;
|
|
336
|
+
} else if (ke.key === 'End') {
|
|
337
|
+
next = len - 1;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Skip disabled tabs
|
|
341
|
+
let attempts = 0;
|
|
342
|
+
while (tabs[next]?.hasAttribute('disabled') && attempts < len) {
|
|
343
|
+
next = ke.key === 'ArrowLeft'
|
|
344
|
+
? (next - 1 + len) % len
|
|
345
|
+
: (next + 1) % len;
|
|
346
|
+
attempts++;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
this._selectTab(next);
|
|
350
|
+
|
|
351
|
+
// Focus the newly active button
|
|
352
|
+
const activeBtn = this.shadowRoot?.querySelector<HTMLElement>(
|
|
353
|
+
`[data-tab-index="${next}"]`
|
|
354
|
+
);
|
|
355
|
+
activeBtn?.focus();
|
|
356
|
+
};
|
|
357
|
+
this.shadowRoot!.addEventListener('keydown', this._onShadowKeydown);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
onUnmount(): void {
|
|
361
|
+
const slot = this.$<HTMLSlotElement>('slot');
|
|
362
|
+
if (slot && this._onSlotChange) {
|
|
363
|
+
slot.removeEventListener('slotchange', this._onSlotChange);
|
|
364
|
+
}
|
|
365
|
+
if (this._onShadowClick) {
|
|
366
|
+
this.shadowRoot?.removeEventListener('click', this._onShadowClick);
|
|
367
|
+
}
|
|
368
|
+
if (this._onShadowKeydown) {
|
|
369
|
+
this.shadowRoot?.removeEventListener('keydown', this._onShadowKeydown);
|
|
370
|
+
}
|
|
371
|
+
this._onSlotChange = null;
|
|
372
|
+
this._onShadowClick = null;
|
|
373
|
+
this._onShadowKeydown = null;
|
|
374
|
+
this._resizeObserver?.disconnect();
|
|
375
|
+
this._resizeObserver = null;
|
|
376
|
+
this._scrollListenerSet = false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Attribute changes from outside (e.g. setting active="2" programmatically).
|
|
381
|
+
* variant change is handled automatically by :host([variant]) CSS selectors.
|
|
382
|
+
*/
|
|
383
|
+
attributeChangedCallback(
|
|
384
|
+
name: string,
|
|
385
|
+
oldValue: string | null,
|
|
386
|
+
newValue: string | null
|
|
387
|
+
): void {
|
|
388
|
+
if (!this._mounted || oldValue === newValue) return;
|
|
389
|
+
if (name === 'active') {
|
|
390
|
+
const index = parseInt(newValue ?? '0', 10);
|
|
391
|
+
if (!isNaN(index) && index !== this._activeIndex) {
|
|
392
|
+
this._activeIndex = index;
|
|
393
|
+
this._buildTabBar();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// variant + transition: :host([attr="..."]) CSS handles appearance — no JS needed
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ─── Private helpers ────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
/** Returns all direct nc-tab-item children (light DOM). */
|
|
402
|
+
private _getTabItems(): HTMLElement[] {
|
|
403
|
+
return Array.from(this.querySelectorAll<HTMLElement>(':scope > nc-tab-item'));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Rebuilds the tab bar buttons from child nc-tab-item labels,
|
|
408
|
+
* then syncs the active attribute on each tab item panel.
|
|
409
|
+
*/
|
|
410
|
+
private _buildTabBar(): void {
|
|
411
|
+
const bar = this.$('.nc-tabs__bar');
|
|
412
|
+
if (!bar) return;
|
|
413
|
+
|
|
414
|
+
const tabs = this._getTabItems();
|
|
415
|
+
|
|
416
|
+
// Clamp active index to valid range
|
|
417
|
+
if (this._activeIndex >= tabs.length) {
|
|
418
|
+
this._activeIndex = Math.max(0, tabs.length - 1);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
this._persistActiveIndex();
|
|
422
|
+
|
|
423
|
+
bar.innerHTML = tabs.map((tab, i) => {
|
|
424
|
+
const label = tab.getAttribute('label') ?? `Tab ${i + 1}`;
|
|
425
|
+
const disabled = tab.hasAttribute('disabled');
|
|
426
|
+
const isActive = i === this._activeIndex;
|
|
427
|
+
const classes = [
|
|
428
|
+
'nc-tabs__btn',
|
|
429
|
+
isActive ? 'nc-tabs__btn--active' : '',
|
|
430
|
+
disabled ? 'nc-tabs__btn--disabled' : '',
|
|
431
|
+
].filter(Boolean).join(' ');
|
|
432
|
+
|
|
433
|
+
return `<button
|
|
434
|
+
class="${classes}"
|
|
435
|
+
role="tab"
|
|
436
|
+
aria-selected="${isActive}"
|
|
437
|
+
aria-disabled="${disabled}"
|
|
438
|
+
tabindex="${isActive ? 0 : -1}"
|
|
439
|
+
data-tab-index="${i}"
|
|
440
|
+
${disabled ? 'disabled' : ''}
|
|
441
|
+
>${label}</button>`;
|
|
442
|
+
}).join('');
|
|
443
|
+
|
|
444
|
+
// Sync active attribute on each panel (drives :host([active]) CSS in nc-tab-item)
|
|
445
|
+
// Also sync data-nc-transition so nc-tab-item picks the correct animation variant
|
|
446
|
+
const transition = this.getAttribute('transition') || 'fade';
|
|
447
|
+
tabs.forEach((tab, i) => {
|
|
448
|
+
tab.setAttribute('data-nc-transition', transition);
|
|
449
|
+
if (i === this._activeIndex) {
|
|
450
|
+
tab.setAttribute('active', '');
|
|
451
|
+
} else {
|
|
452
|
+
tab.removeAttribute('active');
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
this._setupScrollArrows();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ─── Scroll arrow helpers ───────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
private _setupScrollArrows(): void {
|
|
462
|
+
const bar = this.$<HTMLElement>('.nc-tabs__bar');
|
|
463
|
+
if (!bar) return;
|
|
464
|
+
|
|
465
|
+
if (!this._scrollListenerSet) {
|
|
466
|
+
bar.addEventListener('scroll', () => this._updateScrollBtns(), { passive: true });
|
|
467
|
+
|
|
468
|
+
this.$<HTMLButtonElement>('.nc-tabs__scroll-btn--prev')
|
|
469
|
+
?.addEventListener('click', () => { bar.scrollBy({ left: -150, behavior: 'smooth' }); });
|
|
470
|
+
this.$<HTMLButtonElement>('.nc-tabs__scroll-btn--next')
|
|
471
|
+
?.addEventListener('click', () => { bar.scrollBy({ left: 150, behavior: 'smooth' }); });
|
|
472
|
+
|
|
473
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
474
|
+
this._resizeObserver = new ResizeObserver(() => this._updateScrollBtns());
|
|
475
|
+
this._resizeObserver.observe(bar);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
this._scrollListenerSet = true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
this._updateScrollBtns();
|
|
482
|
+
// Scroll the active tab into view after rebuilding the bar
|
|
483
|
+
requestAnimationFrame(() => {
|
|
484
|
+
this.shadowRoot?.querySelector<HTMLElement>('.nc-tabs__btn--active')
|
|
485
|
+
?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private _updateScrollBtns(): void {
|
|
490
|
+
const bar = this.$<HTMLElement>('.nc-tabs__bar');
|
|
491
|
+
const prevBtn = this.$<HTMLElement>('.nc-tabs__scroll-btn--prev');
|
|
492
|
+
const nextBtn = this.$<HTMLElement>('.nc-tabs__scroll-btn--next');
|
|
493
|
+
if (!bar || !prevBtn || !nextBtn) return;
|
|
494
|
+
|
|
495
|
+
const canScrollLeft = bar.scrollLeft > 1;
|
|
496
|
+
const canScrollRight = bar.scrollLeft < bar.scrollWidth - bar.clientWidth - 1;
|
|
497
|
+
|
|
498
|
+
prevBtn.style.display = canScrollLeft ? 'flex' : 'none';
|
|
499
|
+
nextBtn.style.display = canScrollRight ? 'flex' : 'none';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Selects a tab by index — updates state, DOM, host attribute, and emits event. */
|
|
503
|
+
private _selectTab(index: number): void {
|
|
504
|
+
const tabs = this._getTabItems();
|
|
505
|
+
if (index < 0 || index >= tabs.length) return;
|
|
506
|
+
if (tabs[index]?.hasAttribute('disabled')) return;
|
|
507
|
+
if (index === this._activeIndex) return;
|
|
508
|
+
|
|
509
|
+
this._activeIndex = index;
|
|
510
|
+
this._buildTabBar();
|
|
511
|
+
|
|
512
|
+
// Keep host attribute in sync so external code can read it
|
|
513
|
+
// Use setAttribute silently — attributeChangedCallback guard (oldValue !== newValue) prevents loops
|
|
514
|
+
this.setAttribute('active', String(index));
|
|
515
|
+
|
|
516
|
+
this.emitEvent<{ index: number; label: string | null }>('nc-tab-change', {
|
|
517
|
+
index,
|
|
518
|
+
label: tabs[index].getAttribute('label'),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private _storageKey(): string | null {
|
|
523
|
+
if (!this.hasAttribute('persist')) return null;
|
|
524
|
+
const explicit = this.getAttribute('persist-key');
|
|
525
|
+
if (explicit) return explicit;
|
|
526
|
+
if (this.id) return `nc-tabs:${window.location.pathname}:${this.id}`;
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private _readPersistedIndex(): number | null {
|
|
531
|
+
const key = this._storageKey();
|
|
532
|
+
if (!key) return null;
|
|
533
|
+
try {
|
|
534
|
+
const raw = sessionStorage.getItem(key);
|
|
535
|
+
if (raw === null) return null;
|
|
536
|
+
const index = parseInt(raw, 10);
|
|
537
|
+
return Number.isNaN(index) ? null : index;
|
|
538
|
+
} catch {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private _persistActiveIndex(): void {
|
|
544
|
+
const key = this._storageKey();
|
|
545
|
+
if (!key) return;
|
|
546
|
+
try {
|
|
547
|
+
sessionStorage.setItem(key, String(this._activeIndex));
|
|
548
|
+
} catch {
|
|
549
|
+
// Ignore storage failures (private mode / quota / disabled storage)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
defineComponent('nc-tabs', NcTabs);
|