@karyl-chan/ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +58 -0
- package/src/components/AppBadge.vue +155 -0
- package/src/components/AppButton.vue +171 -0
- package/src/components/AppConfirmDialog.vue +176 -0
- package/src/components/AppItemCard.vue +216 -0
- package/src/components/AppMenu.vue +197 -0
- package/src/components/AppMenuItem.vue +43 -0
- package/src/components/AppModal.vue +257 -0
- package/src/components/AppPopover.vue +275 -0
- package/src/components/AppSelect.vue +89 -0
- package/src/components/AppSelectField.vue +260 -0
- package/src/components/AppTabs.vue +310 -0
- package/src/components/AppTabsRouted.vue +409 -0
- package/src/components/AppTextArea.vue +128 -0
- package/src/components/AppTextField.vue +165 -0
- package/src/components/AppToast.vue +65 -0
- package/src/components/AppToggle.vue +105 -0
- package/src/components/Draggable.vue +178 -0
- package/src/components/GlobalConfirmDialog.vue +25 -0
- package/src/components/UnreadPill.vue +28 -0
- package/src/components/UserAvatar.vue +114 -0
- package/src/components/UserCard.vue +274 -0
- package/src/components/UserItem.vue +167 -0
- package/src/components/tab-types.ts +9 -0
- package/src/composables/use-app-shell.ts +65 -0
- package/src/composables/use-breakpoint.ts +14 -0
- package/src/composables/use-click-outside-stack.ts +67 -0
- package/src/composables/use-color-scheme.ts +75 -0
- package/src/composables/use-confirm.ts +36 -0
- package/src/composables/use-drawer.css +47 -0
- package/src/composables/use-drawer.ts +58 -0
- package/src/composables/use-escape-stack.ts +80 -0
- package/src/composables/use-file-drop.ts +47 -0
- package/src/composables/use-long-press.ts +62 -0
- package/src/composables/use-popover.css +32 -0
- package/src/composables/use-popover.ts +1324 -0
- package/src/composables/use-shift-key.ts +18 -0
- package/src/index.ts +75 -0
- package/src/lib/avatar.ts +35 -0
- package/src/reset.css +53 -0
- package/src/stores/toastStore.ts +26 -0
- package/src/tokens.css +159 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
<script setup lang="ts" generic="V extends string | number | null">
|
|
2
|
+
import { computed, nextTick, ref, watch } from 'vue';
|
|
3
|
+
import AppSelect from './AppSelect.vue';
|
|
4
|
+
import type { Placement } from '../composables/use-popover';
|
|
5
|
+
import type { DrawerPlacement } from '../composables/use-drawer';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Drop-in replacement for native `<select>` built on AppSelect, so the
|
|
9
|
+
* picker honours the same viewport-aware popover/drawer split the rest
|
|
10
|
+
* of the admin surface uses. Owns:
|
|
11
|
+
* - A value-model (`v-model`).
|
|
12
|
+
* - A trigger button styled to match form inputs.
|
|
13
|
+
* - The flat / grouped option list rendering.
|
|
14
|
+
* - Optional in-dropdown filter (off by default) for long option lists.
|
|
15
|
+
*
|
|
16
|
+
* `options` accepts either `{ value, label }[]` (flat) or items with a
|
|
17
|
+
* `group` field (header pulled from the unique non-null group strings,
|
|
18
|
+
* preserving first-seen order).
|
|
19
|
+
*
|
|
20
|
+
* Labels are passed in as props with English defaults — this component
|
|
21
|
+
* does not depend on vue-i18n.
|
|
22
|
+
*/
|
|
23
|
+
export interface SelectOption<V> {
|
|
24
|
+
value: V;
|
|
25
|
+
label: string;
|
|
26
|
+
group?: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(defineProps<{
|
|
30
|
+
modelValue: V;
|
|
31
|
+
options: SelectOption<V>[];
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
placement?: Placement;
|
|
35
|
+
drawerPlacement?: DrawerPlacement;
|
|
36
|
+
drawerTitle?: string;
|
|
37
|
+
/** Show a search box at the top of the dropdown. Default: false. */
|
|
38
|
+
filter?: boolean;
|
|
39
|
+
/** Placeholder for the filter input. */
|
|
40
|
+
filterPlaceholder?: string;
|
|
41
|
+
/** Text shown when filter yields no matches. */
|
|
42
|
+
noMatchesLabel?: string;
|
|
43
|
+
}>(), {
|
|
44
|
+
placeholder: '',
|
|
45
|
+
disabled: false,
|
|
46
|
+
placement: 'bottom-start',
|
|
47
|
+
drawerPlacement: 'bottom',
|
|
48
|
+
drawerTitle: '',
|
|
49
|
+
filter: false,
|
|
50
|
+
filterPlaceholder: 'Search',
|
|
51
|
+
noMatchesLabel: 'No matches'
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const emit = defineEmits<{
|
|
55
|
+
(e: 'update:modelValue', value: V): void;
|
|
56
|
+
}>();
|
|
57
|
+
|
|
58
|
+
const isOpen = ref(false);
|
|
59
|
+
const filterText = ref('');
|
|
60
|
+
const filterInputRef = ref<HTMLInputElement | null>(null);
|
|
61
|
+
|
|
62
|
+
const selected = computed(() =>
|
|
63
|
+
props.options.find(o => o.value === props.modelValue) ?? null
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// When the filter is on, narrow the option list by case-insensitive
|
|
67
|
+
// substring match against the label. Group headers fall away naturally
|
|
68
|
+
// because the grouping pass below only emits a header when at least
|
|
69
|
+
// one option in that group survives the filter.
|
|
70
|
+
const filteredOptions = computed(() => {
|
|
71
|
+
if (!props.filter) return props.options;
|
|
72
|
+
const needle = filterText.value.trim().toLowerCase();
|
|
73
|
+
if (!needle) return props.options;
|
|
74
|
+
return props.options.filter(o => o.label.toLowerCase().includes(needle));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
interface Group {
|
|
78
|
+
label: string | null;
|
|
79
|
+
items: SelectOption<V>[];
|
|
80
|
+
}
|
|
81
|
+
const groups = computed<Group[]>(() => {
|
|
82
|
+
const out: Group[] = [];
|
|
83
|
+
const byKey = new Map<string, Group>();
|
|
84
|
+
for (const opt of filteredOptions.value) {
|
|
85
|
+
const key = opt.group ?? '';
|
|
86
|
+
let g = byKey.get(key);
|
|
87
|
+
if (!g) {
|
|
88
|
+
g = { label: opt.group ?? null, items: [] };
|
|
89
|
+
byKey.set(key, g);
|
|
90
|
+
out.push(g);
|
|
91
|
+
}
|
|
92
|
+
g.items.push(opt);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const hasGroups = computed(() => groups.value.some(g => g.label !== null));
|
|
98
|
+
const noMatches = computed(() => props.filter && filteredOptions.value.length === 0);
|
|
99
|
+
|
|
100
|
+
// Reset filter on close so the next open starts fresh; auto-focus the
|
|
101
|
+
// filter input on open so the user can type immediately.
|
|
102
|
+
watch(isOpen, (open) => {
|
|
103
|
+
if (!open) {
|
|
104
|
+
filterText.value = '';
|
|
105
|
+
} else if (props.filter) {
|
|
106
|
+
nextTick(() => filterInputRef.value?.focus());
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function pick(value: V) {
|
|
111
|
+
emit('update:modelValue', value);
|
|
112
|
+
isOpen.value = false;
|
|
113
|
+
}
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<template>
|
|
117
|
+
<AppSelect
|
|
118
|
+
v-model:open="isOpen"
|
|
119
|
+
:placement="placement"
|
|
120
|
+
:drawer-placement="drawerPlacement"
|
|
121
|
+
:drawer-title="drawerTitle || placeholder"
|
|
122
|
+
:close-on-item-click="!filter"
|
|
123
|
+
>
|
|
124
|
+
<template #trigger>
|
|
125
|
+
<button type="button" class="select-trigger" :disabled="disabled">
|
|
126
|
+
<span class="label" :class="{ placeholder: !selected }">
|
|
127
|
+
{{ selected?.label ?? placeholder }}
|
|
128
|
+
</span>
|
|
129
|
+
<span class="chevron" :class="{ open: isOpen }">›</span>
|
|
130
|
+
</button>
|
|
131
|
+
</template>
|
|
132
|
+
|
|
133
|
+
<div v-if="filter" class="filter-bar" @click.stop>
|
|
134
|
+
<input
|
|
135
|
+
ref="filterInputRef"
|
|
136
|
+
v-model="filterText"
|
|
137
|
+
type="text"
|
|
138
|
+
class="filter-input"
|
|
139
|
+
:placeholder="filterPlaceholder"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
<ul class="select-list">
|
|
143
|
+
<template v-if="noMatches">
|
|
144
|
+
<li class="empty">{{ noMatchesLabel }}</li>
|
|
145
|
+
</template>
|
|
146
|
+
<template v-else-if="hasGroups">
|
|
147
|
+
<template v-for="(g, gi) in groups" :key="gi">
|
|
148
|
+
<li v-if="g.label !== null" class="group-head">{{ g.label }}</li>
|
|
149
|
+
<li
|
|
150
|
+
v-for="opt in g.items"
|
|
151
|
+
:key="String(opt.value) + '|' + opt.label"
|
|
152
|
+
:class="['option', { active: opt.value === modelValue, indented: g.label !== null }]"
|
|
153
|
+
@click="pick(opt.value)"
|
|
154
|
+
>{{ opt.label }}</li>
|
|
155
|
+
</template>
|
|
156
|
+
</template>
|
|
157
|
+
<template v-else>
|
|
158
|
+
<li
|
|
159
|
+
v-for="opt in filteredOptions"
|
|
160
|
+
:key="String(opt.value) + '|' + opt.label"
|
|
161
|
+
:class="['option', { active: opt.value === modelValue }]"
|
|
162
|
+
@click="pick(opt.value)"
|
|
163
|
+
>{{ opt.label }}</li>
|
|
164
|
+
</template>
|
|
165
|
+
</ul>
|
|
166
|
+
</AppSelect>
|
|
167
|
+
</template>
|
|
168
|
+
|
|
169
|
+
<style scoped>
|
|
170
|
+
.select-trigger {
|
|
171
|
+
display: flex;
|
|
172
|
+
width: 100%;
|
|
173
|
+
align-items: center;
|
|
174
|
+
gap: 0.4rem;
|
|
175
|
+
padding: 0.4rem 0.55rem;
|
|
176
|
+
border: 1px solid var(--border);
|
|
177
|
+
border-radius: var(--radius-sm);
|
|
178
|
+
background: var(--bg-surface);
|
|
179
|
+
color: var(--text);
|
|
180
|
+
font: inherit;
|
|
181
|
+
font-size: 0.9rem;
|
|
182
|
+
cursor: pointer;
|
|
183
|
+
text-align: left;
|
|
184
|
+
min-width: 0;
|
|
185
|
+
}
|
|
186
|
+
.select-trigger:hover:not(:disabled) { background: var(--bg-surface-hover); }
|
|
187
|
+
.select-trigger:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
188
|
+
.select-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
189
|
+
.label {
|
|
190
|
+
flex: 1;
|
|
191
|
+
overflow: hidden;
|
|
192
|
+
text-overflow: ellipsis;
|
|
193
|
+
white-space: nowrap;
|
|
194
|
+
}
|
|
195
|
+
.label.placeholder { color: var(--text-muted); }
|
|
196
|
+
.chevron {
|
|
197
|
+
flex-shrink: 0;
|
|
198
|
+
color: var(--text-muted);
|
|
199
|
+
transition: transform var(--transition-base);
|
|
200
|
+
transform: rotate(90deg);
|
|
201
|
+
font-size: 0.95rem;
|
|
202
|
+
}
|
|
203
|
+
.chevron.open { transform: rotate(270deg); }
|
|
204
|
+
|
|
205
|
+
.filter-bar {
|
|
206
|
+
padding: 0.4rem 0.5rem;
|
|
207
|
+
border-bottom: 1px solid var(--border);
|
|
208
|
+
background: var(--bg-surface);
|
|
209
|
+
position: sticky;
|
|
210
|
+
top: 0;
|
|
211
|
+
z-index: 1;
|
|
212
|
+
}
|
|
213
|
+
.filter-input {
|
|
214
|
+
width: 100%;
|
|
215
|
+
padding: 0.35rem 0.5rem;
|
|
216
|
+
border: 1px solid var(--border);
|
|
217
|
+
border-radius: var(--radius-sm);
|
|
218
|
+
background: var(--bg-surface);
|
|
219
|
+
color: var(--text);
|
|
220
|
+
font: inherit;
|
|
221
|
+
font-size: 0.88rem;
|
|
222
|
+
box-sizing: border-box;
|
|
223
|
+
}
|
|
224
|
+
.filter-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
225
|
+
|
|
226
|
+
.select-list {
|
|
227
|
+
list-style: none;
|
|
228
|
+
margin: 0;
|
|
229
|
+
padding: 0.25rem 0;
|
|
230
|
+
overflow-y: auto;
|
|
231
|
+
max-height: 320px;
|
|
232
|
+
}
|
|
233
|
+
.option {
|
|
234
|
+
padding: 0.45rem 0.85rem;
|
|
235
|
+
cursor: pointer;
|
|
236
|
+
font-size: 0.88rem;
|
|
237
|
+
color: var(--text);
|
|
238
|
+
display: flex;
|
|
239
|
+
align-items: center;
|
|
240
|
+
}
|
|
241
|
+
.option:hover { background: var(--bg-surface-hover); }
|
|
242
|
+
.option.active { background: var(--bg-surface-active); font-weight: 500; }
|
|
243
|
+
.option.indented { padding-left: 1.5rem; }
|
|
244
|
+
.empty {
|
|
245
|
+
padding: 0.6rem 0.85rem;
|
|
246
|
+
color: var(--text-muted);
|
|
247
|
+
font-size: 0.85rem;
|
|
248
|
+
text-align: center;
|
|
249
|
+
}
|
|
250
|
+
.group-head {
|
|
251
|
+
padding: 0.4rem 0.85rem 0.2rem;
|
|
252
|
+
font-size: 0.72rem;
|
|
253
|
+
text-transform: uppercase;
|
|
254
|
+
letter-spacing: 0.04em;
|
|
255
|
+
color: var(--text-muted);
|
|
256
|
+
}
|
|
257
|
+
@media (max-width: 768px) {
|
|
258
|
+
.select-list { max-height: none; }
|
|
259
|
+
}
|
|
260
|
+
</style>
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { Icon } from '@iconify/vue';
|
|
4
|
+
import { useBreakpoint } from '../composables/use-breakpoint';
|
|
5
|
+
import AppSelectField, { type SelectOption } from './AppSelectField.vue';
|
|
6
|
+
import type { TabDef } from './tab-types';
|
|
7
|
+
|
|
8
|
+
export type { TabDef };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* AppTabs — two-level tab strip with viewport-aware fallback. NO
|
|
12
|
+
* vue-router dependency. Tab clicks emit `update:modelValue` /
|
|
13
|
+
* `update:subModelValue`; the parent owns selection state.
|
|
14
|
+
*
|
|
15
|
+
* Desktop:
|
|
16
|
+
* - Primary tabs always render as a horizontal pill row at the top.
|
|
17
|
+
* - Sub-tabs (when provided) render either underneath the primary
|
|
18
|
+
* row (`subLayout: 'top'`) or as a full-height vertical sidebar
|
|
19
|
+
* to the left of the panel (`subLayout: 'sidebar'`).
|
|
20
|
+
*
|
|
21
|
+
* Mobile (≤768px):
|
|
22
|
+
* - Both rows collapse to AppSelectField dropdowns stacked vertically.
|
|
23
|
+
*
|
|
24
|
+
* If you need URL-synced tabs with deep-linkable hrefs, use
|
|
25
|
+
* `AppTabsRouted` from this package (a thin wrapper that adds
|
|
26
|
+
* `vue-router` integration on top).
|
|
27
|
+
*/
|
|
28
|
+
const props = withDefaults(defineProps<{
|
|
29
|
+
modelValue: string;
|
|
30
|
+
tabs: TabDef[];
|
|
31
|
+
subModelValue?: string;
|
|
32
|
+
subTabs?: TabDef[];
|
|
33
|
+
/** Where sub-tabs render on desktop. Has no effect on mobile. */
|
|
34
|
+
subLayout?: 'top' | 'sidebar';
|
|
35
|
+
}>(), {
|
|
36
|
+
subModelValue: undefined,
|
|
37
|
+
subTabs: () => [],
|
|
38
|
+
subLayout: 'top'
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{
|
|
42
|
+
(e: 'update:modelValue', value: string): void;
|
|
43
|
+
(e: 'update:subModelValue', value: string): void;
|
|
44
|
+
}>();
|
|
45
|
+
|
|
46
|
+
const { isMobile } = useBreakpoint();
|
|
47
|
+
|
|
48
|
+
const hasSub = computed(() => props.subTabs.length > 0);
|
|
49
|
+
|
|
50
|
+
function pickPrimary(key: string) { emit('update:modelValue', key); }
|
|
51
|
+
function pickSub(key: string) { emit('update:subModelValue', key); }
|
|
52
|
+
|
|
53
|
+
const primaryOptions = computed<SelectOption<string>[]>(() =>
|
|
54
|
+
props.tabs.map(t => ({ value: t.key, label: t.label }))
|
|
55
|
+
);
|
|
56
|
+
const subOptions = computed<SelectOption<string>[]>(() =>
|
|
57
|
+
props.subTabs.map(t => ({ value: t.key, label: t.label }))
|
|
58
|
+
);
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<!-- Mobile: two stacked dropdowns. -->
|
|
63
|
+
<div v-if="isMobile" class="tabs-root mobile">
|
|
64
|
+
<div class="dropdowns">
|
|
65
|
+
<AppSelectField
|
|
66
|
+
:model-value="modelValue"
|
|
67
|
+
:options="primaryOptions"
|
|
68
|
+
@update:model-value="pickPrimary"
|
|
69
|
+
/>
|
|
70
|
+
<AppSelectField
|
|
71
|
+
v-if="hasSub && subModelValue !== undefined"
|
|
72
|
+
:model-value="subModelValue"
|
|
73
|
+
:options="subOptions"
|
|
74
|
+
@update:model-value="pickSub"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="panel">
|
|
78
|
+
<slot />
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Desktop, sub-tabs below primary. -->
|
|
83
|
+
<div
|
|
84
|
+
v-else-if="!hasSub || subLayout === 'top'"
|
|
85
|
+
class="tabs-root desktop top"
|
|
86
|
+
>
|
|
87
|
+
<nav class="primary-row" role="tablist">
|
|
88
|
+
<button
|
|
89
|
+
v-for="t in tabs"
|
|
90
|
+
:key="t.key"
|
|
91
|
+
type="button"
|
|
92
|
+
role="tab"
|
|
93
|
+
:class="['tab', { active: t.key === modelValue }]"
|
|
94
|
+
:aria-selected="t.key === modelValue"
|
|
95
|
+
:disabled="t.disabled"
|
|
96
|
+
@click="!t.disabled && pickPrimary(t.key)"
|
|
97
|
+
>
|
|
98
|
+
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
99
|
+
<span>{{ t.label }}</span>
|
|
100
|
+
</button>
|
|
101
|
+
</nav>
|
|
102
|
+
<nav v-if="hasSub" class="sub-row" role="tablist">
|
|
103
|
+
<button
|
|
104
|
+
v-for="t in subTabs"
|
|
105
|
+
:key="t.key"
|
|
106
|
+
type="button"
|
|
107
|
+
role="tab"
|
|
108
|
+
:class="['sub-tab', { active: t.key === subModelValue }]"
|
|
109
|
+
:aria-selected="t.key === subModelValue"
|
|
110
|
+
:disabled="t.disabled"
|
|
111
|
+
@click="!t.disabled && pickSub(t.key)"
|
|
112
|
+
>
|
|
113
|
+
<Icon v-if="t.icon" :icon="t.icon" width="14" height="14" />
|
|
114
|
+
<span>{{ t.label }}</span>
|
|
115
|
+
</button>
|
|
116
|
+
</nav>
|
|
117
|
+
<div class="panel">
|
|
118
|
+
<slot />
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<!-- Desktop, sub-tabs as a vertical sidebar. -->
|
|
123
|
+
<div v-else class="tabs-root desktop sidebar">
|
|
124
|
+
<nav class="primary-row" role="tablist">
|
|
125
|
+
<button
|
|
126
|
+
v-for="t in tabs"
|
|
127
|
+
:key="t.key"
|
|
128
|
+
type="button"
|
|
129
|
+
role="tab"
|
|
130
|
+
:class="['tab', { active: t.key === modelValue }]"
|
|
131
|
+
:aria-selected="t.key === modelValue"
|
|
132
|
+
:disabled="t.disabled"
|
|
133
|
+
@click="!t.disabled && pickPrimary(t.key)"
|
|
134
|
+
>
|
|
135
|
+
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
136
|
+
<span>{{ t.label }}</span>
|
|
137
|
+
</button>
|
|
138
|
+
</nav>
|
|
139
|
+
<div class="split">
|
|
140
|
+
<nav class="sub-side" role="tablist">
|
|
141
|
+
<button
|
|
142
|
+
v-for="t in subTabs"
|
|
143
|
+
:key="t.key"
|
|
144
|
+
type="button"
|
|
145
|
+
role="tab"
|
|
146
|
+
:class="['sub-side-tab', { active: t.key === subModelValue }]"
|
|
147
|
+
:aria-selected="t.key === subModelValue"
|
|
148
|
+
:disabled="t.disabled"
|
|
149
|
+
@click="!t.disabled && pickSub(t.key)"
|
|
150
|
+
>
|
|
151
|
+
<Icon v-if="t.icon" :icon="t.icon" width="16" height="16" />
|
|
152
|
+
<span>{{ t.label }}</span>
|
|
153
|
+
</button>
|
|
154
|
+
</nav>
|
|
155
|
+
<div class="panel side-panel">
|
|
156
|
+
<slot />
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</template>
|
|
161
|
+
|
|
162
|
+
<style scoped>
|
|
163
|
+
.tabs-root {
|
|
164
|
+
display: flex;
|
|
165
|
+
flex-direction: column;
|
|
166
|
+
gap: 0.7rem;
|
|
167
|
+
min-height: 0;
|
|
168
|
+
/* Span the parent's cross-axis. Without this, a parent that uses
|
|
169
|
+
`align-items: flex-start` (a common pattern when other siblings
|
|
170
|
+
are bare buttons that shouldn't stretch) collapses the tabs
|
|
171
|
+
strip to its own content width. */
|
|
172
|
+
width: 100%;
|
|
173
|
+
flex: 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.primary-row {
|
|
177
|
+
display: flex;
|
|
178
|
+
gap: 0.4rem;
|
|
179
|
+
border-bottom: 1px solid var(--border);
|
|
180
|
+
padding-bottom: 0.5rem;
|
|
181
|
+
/* Horizontal scroll when tabs don't fit; never vertical. The
|
|
182
|
+
browser implicitly upgrades the other axis to `auto` whenever
|
|
183
|
+
one axis is non-visible, so `overflow-y: hidden` is required —
|
|
184
|
+
otherwise an unwanted vertical scrollbar appears whenever the
|
|
185
|
+
border-bottom / focus ring nudges the box past its parent's
|
|
186
|
+
computed height (which it does for sub-row's active underline). */
|
|
187
|
+
overflow-x: auto;
|
|
188
|
+
overflow-y: hidden;
|
|
189
|
+
scrollbar-width: thin;
|
|
190
|
+
flex-shrink: 0;
|
|
191
|
+
}
|
|
192
|
+
.tab {
|
|
193
|
+
background: var(--bg-surface-2);
|
|
194
|
+
border: 1px solid transparent;
|
|
195
|
+
border-radius: var(--radius-base);
|
|
196
|
+
padding: 0.4rem 0.95rem;
|
|
197
|
+
cursor: pointer;
|
|
198
|
+
color: var(--text);
|
|
199
|
+
font: inherit;
|
|
200
|
+
font-size: 0.9rem;
|
|
201
|
+
font-weight: 500;
|
|
202
|
+
display: inline-flex;
|
|
203
|
+
align-items: center;
|
|
204
|
+
gap: 0.4rem;
|
|
205
|
+
white-space: nowrap;
|
|
206
|
+
text-decoration: none;
|
|
207
|
+
}
|
|
208
|
+
.tab:hover:not(:disabled) { background: var(--bg-surface-hover); }
|
|
209
|
+
.tab.active {
|
|
210
|
+
background: var(--accent-bg);
|
|
211
|
+
border-color: var(--accent);
|
|
212
|
+
color: var(--accent-text-strong);
|
|
213
|
+
}
|
|
214
|
+
.tab:disabled { opacity: 0.45; cursor: default; }
|
|
215
|
+
|
|
216
|
+
.sub-row {
|
|
217
|
+
display: flex;
|
|
218
|
+
gap: 0.6rem;
|
|
219
|
+
border-bottom: 1px solid var(--border);
|
|
220
|
+
overflow-x: auto;
|
|
221
|
+
overflow-y: hidden;
|
|
222
|
+
scrollbar-width: thin;
|
|
223
|
+
margin-top: -0.3rem;
|
|
224
|
+
flex-shrink: 0;
|
|
225
|
+
}
|
|
226
|
+
.sub-tab {
|
|
227
|
+
background: none;
|
|
228
|
+
border: none;
|
|
229
|
+
color: var(--text-muted);
|
|
230
|
+
font: inherit;
|
|
231
|
+
font-size: 0.85rem;
|
|
232
|
+
font-weight: 500;
|
|
233
|
+
padding: 0.45rem 0.2rem;
|
|
234
|
+
cursor: pointer;
|
|
235
|
+
border-bottom: 2px solid transparent;
|
|
236
|
+
margin-bottom: -1px;
|
|
237
|
+
display: inline-flex;
|
|
238
|
+
align-items: center;
|
|
239
|
+
gap: 0.3rem;
|
|
240
|
+
white-space: nowrap;
|
|
241
|
+
text-decoration: none;
|
|
242
|
+
}
|
|
243
|
+
.sub-tab:hover:not(:disabled) { color: var(--text); }
|
|
244
|
+
.sub-tab.active { color: var(--accent-text-strong); border-color: var(--accent); }
|
|
245
|
+
.sub-tab:disabled { opacity: 0.45; cursor: default; }
|
|
246
|
+
|
|
247
|
+
.split {
|
|
248
|
+
display: flex;
|
|
249
|
+
gap: 0.9rem;
|
|
250
|
+
flex: 1;
|
|
251
|
+
min-height: 0;
|
|
252
|
+
}
|
|
253
|
+
.sub-side {
|
|
254
|
+
display: flex;
|
|
255
|
+
flex-direction: column;
|
|
256
|
+
gap: 0.15rem;
|
|
257
|
+
width: 200px;
|
|
258
|
+
flex-shrink: 0;
|
|
259
|
+
border-right: 1px solid var(--border);
|
|
260
|
+
padding-right: 0.6rem;
|
|
261
|
+
overflow-y: auto;
|
|
262
|
+
}
|
|
263
|
+
.sub-side-tab {
|
|
264
|
+
background: none;
|
|
265
|
+
border: 1px solid transparent;
|
|
266
|
+
border-radius: var(--radius-base);
|
|
267
|
+
padding: 0.5rem 0.7rem;
|
|
268
|
+
text-align: left;
|
|
269
|
+
color: var(--text-muted);
|
|
270
|
+
font: inherit;
|
|
271
|
+
font-size: 0.88rem;
|
|
272
|
+
font-weight: 500;
|
|
273
|
+
cursor: pointer;
|
|
274
|
+
display: flex;
|
|
275
|
+
align-items: center;
|
|
276
|
+
gap: 0.5rem;
|
|
277
|
+
text-decoration: none;
|
|
278
|
+
}
|
|
279
|
+
.sub-side-tab:hover:not(:disabled) {
|
|
280
|
+
background: var(--bg-surface-hover);
|
|
281
|
+
color: var(--text);
|
|
282
|
+
}
|
|
283
|
+
.sub-side-tab.active {
|
|
284
|
+
background: var(--accent-bg);
|
|
285
|
+
color: var(--accent-text-strong);
|
|
286
|
+
border-color: var(--accent);
|
|
287
|
+
}
|
|
288
|
+
.sub-side-tab:disabled { opacity: 0.45; cursor: default; }
|
|
289
|
+
|
|
290
|
+
.panel {
|
|
291
|
+
flex: 1;
|
|
292
|
+
min-width: 0;
|
|
293
|
+
min-height: 0;
|
|
294
|
+
overflow-y: auto;
|
|
295
|
+
}
|
|
296
|
+
.side-panel {
|
|
297
|
+
padding-right: 0.2rem;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.tabs-root.mobile { gap: 0.5rem; }
|
|
301
|
+
.dropdowns {
|
|
302
|
+
display: flex;
|
|
303
|
+
flex-direction: column;
|
|
304
|
+
gap: 0.4rem;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
@media (max-width: 640px) {
|
|
308
|
+
.sub-side { width: 160px; }
|
|
309
|
+
}
|
|
310
|
+
</style>
|