-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(nav): responsive side rail (landscape) + bottom nav (portrait) with hamburger overflow #262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e9ba305
9e79cba
119b73c
b07b1fb
fd0f86e
bf28b7c
3a87b9c
b4cbd21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,3 +1,4 @@ | ||||||
| @import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,1,0&display=swap'); | ||||||
| @import 'tailwindcss'; | ||||||
|
|
||||||
| @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; | ||||||
|
|
@@ -146,3 +147,25 @@ | |||||
| .dark .text-green-700 { | ||||||
| color: rgb(134 239 172); | ||||||
| } | ||||||
|
|
||||||
| /* Material Symbols Rounded — filled, wght 400 (bolder), rounded */ | ||||||
| .material-symbols-rounded { | ||||||
| font-family: 'Material Symbols Rounded'; | ||||||
| font-weight: normal; | ||||||
| font-style: normal; | ||||||
| font-size: 24px; | ||||||
| line-height: 1; | ||||||
| letter-spacing: normal; | ||||||
| text-transform: none; | ||||||
| display: inline-block; | ||||||
| white-space: nowrap; | ||||||
| word-wrap: normal; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Replace deprecated Stylelint rejects Proposed fix- word-wrap: normal;
+ overflow-wrap: normal;📝 Committable suggestion
Suggested change
🧰 Tools🪛 Stylelint (17.14.0)[error] 162-162: Expected "word-wrap" to be "overflow-wrap" (property-no-deprecated) (property-no-deprecated) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||||||
| direction: ltr; | ||||||
| -webkit-font-feature-settings: 'liga'; | ||||||
| -webkit-font-smoothing: antialiased; | ||||||
| font-variation-settings: | ||||||
| 'FILL' 1, | ||||||
| 'wght' 400, | ||||||
| 'GRAD' 0, | ||||||
| 'opsz' 24; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| <script setup lang="ts"> | ||
| import { Link, usePage } from '@inertiajs/vue3'; | ||
| import { computed } from 'vue'; | ||
|
|
||
| import MaterialIcon from '@/components/ui/MaterialIcon.vue'; | ||
| import { useBottomNavCustomization } from '@/lib/useBottomNavCustomization'; | ||
|
|
||
| const page = usePage(); | ||
| const currentUrl = computed(() => String(page.url)); | ||
|
|
||
| const { bottomNavItems } = useBottomNavCustomization(); | ||
|
|
||
| const homeHref = computed(() => { | ||
| if (typeof window !== 'undefined') { | ||
| try { | ||
| const pref = localStorage.getItem('preferred_course'); | ||
|
|
||
| if (pref === 'ssc') { | ||
| return '/ssc'; | ||
| } | ||
| } catch {} | ||
| } | ||
|
|
||
| return currentUrl.value.startsWith('/ssc') ? '/ssc' : '/'; | ||
| }); | ||
|
|
||
| const isActive = (href: string, match?: (url: string) => boolean) => { | ||
| if (match) { | ||
| return match(currentUrl.value); | ||
| } | ||
|
|
||
| return currentUrl.value.startsWith(href); | ||
| }; | ||
|
|
||
| const resolvedHref = (item: { href: string }) => { | ||
| if (item.href === '/') { | ||
| return homeHref.value; | ||
| } | ||
|
|
||
| return item.href; | ||
| }; | ||
| </script> | ||
|
|
||
| <template> | ||
| <!-- YT / YT Music style: full-width bottom bar, icon above label, active filled --> | ||
| <nav | ||
| class="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200/70 bg-white/95 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl dark:border-slate-800 dark:bg-slate-900/95" | ||
| aria-label="Bottom navigation" | ||
| > | ||
| <div | ||
| class="mx-auto flex max-w-md items-center justify-around px-1 py-2" | ||
| > | ||
| <Link | ||
| v-for="item in bottomNavItems" | ||
| :key="item.href" | ||
| :href="resolvedHref(item)" | ||
| :class="[ | ||
| 'flex min-w-0 flex-1 flex-col items-center gap-1 rounded-xl px-2 py-2 transition-all duration-150 ease-out', | ||
| isActive(item.href, item.match) | ||
| ? 'text-slate-900 dark:text-white' | ||
| : 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200', | ||
| ]" | ||
| > | ||
| <MaterialIcon | ||
| :name="item.icon" | ||
| :size="26" | ||
| :filled="isActive(item.href, item.match)" | ||
| :weight="400" | ||
| :class="[ | ||
| 'transition-transform duration-150', | ||
| isActive(item.href, item.match) | ||
| ? 'scale-[1.02] text-slate-900 dark:text-white' | ||
| : 'text-slate-500 dark:text-slate-400', | ||
| ]" | ||
| /> | ||
| <span | ||
| :class="[ | ||
| 'text-[10px] leading-none tracking-wide antialiased', | ||
| isActive(item.href, item.match) | ||
| ? 'font-bold' | ||
| : 'font-medium', | ||
| ]" | ||
| >{{ item.label }}</span | ||
| > | ||
| </Link> | ||
| </div> | ||
| </nav> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,264 @@ | ||
| <script setup lang="ts"> | ||
| import { ref } from 'vue'; | ||
|
|
||
| import MaterialIcon from '@/components/ui/MaterialIcon.vue'; | ||
| import { useBottomNavCustomization } from '@/lib/useBottomNavCustomization'; | ||
|
|
||
| const { | ||
| bottomNavItems, | ||
| availableItems, | ||
| homeItem, | ||
| accountItem, | ||
| middleHrefs, | ||
| canAdd, | ||
| canRemove, | ||
| addItem, | ||
| removeItem, | ||
| reorder, | ||
| reset, | ||
| MIN_TOTAL, | ||
| MAX_TOTAL, | ||
| } = useBottomNavCustomization(); | ||
|
|
||
| const dragIndex = ref<number | null>(null); | ||
|
|
||
| const onDragStart = (index: number, e: DragEvent) => { | ||
| dragIndex.value = index; | ||
|
|
||
| if (e.dataTransfer) { | ||
| e.dataTransfer.effectAllowed = 'move'; | ||
| e.dataTransfer.setData('text/plain', String(index)); | ||
| } | ||
| }; | ||
|
|
||
| const onDragOver = (e: DragEvent) => { | ||
| e.preventDefault(); | ||
|
|
||
| if (e.dataTransfer) { | ||
| e.dataTransfer.dropEffect = 'move'; | ||
| } | ||
| }; | ||
|
|
||
| const onDrop = (targetIndex: number, e: DragEvent) => { | ||
| e.preventDefault(); | ||
| const from = dragIndex.value; | ||
|
|
||
| if (from === null || from === targetIndex) { | ||
| dragIndex.value = null; | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| reorder(from, targetIndex); | ||
| dragIndex.value = null; | ||
| }; | ||
|
|
||
| const onDragEnd = () => { | ||
| dragIndex.value = null; | ||
| }; | ||
|
|
||
| const handleAdd = (href: string) => { | ||
| if (!canAdd.value) { | ||
| return; | ||
| } | ||
|
|
||
| addItem(href); | ||
| }; | ||
|
|
||
| const handleRemove = (href: string) => { | ||
| if (!canRemove.value) { | ||
| return; | ||
| } | ||
|
|
||
| removeItem(href); | ||
| }; | ||
| </script> | ||
|
|
||
| <template> | ||
| <div | ||
| class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900" | ||
| > | ||
| <div class="mb-6"> | ||
| <h3 | ||
| class="text-base font-semibold text-slate-900 dark:text-gray-100" | ||
| > | ||
| Bottom navigation | ||
| </h3> | ||
| <p class="mt-1 text-xs text-slate-500 dark:text-gray-400"> | ||
| Customize your mobile bottom bar (3–5 items). Home and Account | ||
| are pinned — drag the middle items to reorder. Changes save | ||
| automatically to this device. | ||
| </p> | ||
| <p class="mt-2 text-xs font-medium"> | ||
| <span | ||
| :class=" | ||
| bottomNavItems.length < MIN_TOTAL || | ||
| bottomNavItems.length > MAX_TOTAL | ||
| ? 'text-amber-600' | ||
| : 'text-slate-500 dark:text-gray-400' | ||
| " | ||
| > | ||
| {{ bottomNavItems.length }} / {{ MAX_TOTAL }} items | ||
| </span> | ||
| <span class="mx-2 text-slate-300">·</span> | ||
| <button | ||
| type="button" | ||
| @click="reset" | ||
| class="inline-flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-700 dark:text-indigo-400" | ||
| > | ||
| <MaterialIcon name="restart_alt" :size="14" /> Reset | ||
| </button> | ||
| </p> | ||
| </div> | ||
|
|
||
| <!-- Current bottom bar (pinned + draggable middle) --> | ||
| <div> | ||
| <p | ||
| class="mb-2 text-[11px] font-bold tracking-widest text-slate-400 uppercase dark:text-gray-500" | ||
| > | ||
| Bottom bar — drag middle to reorder | ||
| </p> | ||
| <ul class="space-y-2"> | ||
| <!-- Home pinned --> | ||
| <li | ||
| class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 dark:border-gray-700 dark:bg-gray-800/60" | ||
| > | ||
| <MaterialIcon | ||
| name="lock" | ||
| :size="16" | ||
| class="shrink-0 text-slate-400" | ||
| /> | ||
| <MaterialIcon | ||
| :name="homeItem.icon" | ||
| :size="22" | ||
| class="shrink-0 text-slate-700 dark:text-gray-300" | ||
| /> | ||
| <span | ||
| class="flex-1 text-sm font-semibold text-slate-900 dark:text-gray-100" | ||
| >{{ homeItem.label }}</span | ||
| > | ||
| <span | ||
| class="rounded-full bg-slate-900 px-2 py-0.5 text-[10px] font-bold text-white dark:bg-white dark:text-slate-900" | ||
| >Pinned first</span | ||
| > | ||
| </li> | ||
|
|
||
| <!-- Middle draggable --> | ||
| <li | ||
| v-for="(href, idx) in middleHrefs" | ||
| :key="href" | ||
| draggable="true" | ||
| @dragstart="onDragStart(idx, $event)" | ||
| @dragover="onDragOver" | ||
| @drop="onDrop(idx, $event)" | ||
| @dragend="onDragEnd" | ||
|
Comment on lines
+150
to
+154
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Add keyboard controls for reordering. The reorder operation is available only through drag events. Keyboard users cannot reorder middle navigation items because this list has no focusable move controls or keyboard handler. Add “Move earlier” and “Move later” buttons that call 🤖 Prompt for AI Agents |
||
| :class="[ | ||
| 'flex items-center gap-3 rounded-xl border bg-white px-3 py-2.5 shadow-sm transition dark:bg-gray-800', | ||
| dragIndex === idx | ||
| ? 'border-indigo-300 ring-2 ring-indigo-200 dark:border-indigo-700' | ||
| : 'border-slate-200 dark:border-gray-700', | ||
| ]" | ||
| > | ||
| <MaterialIcon | ||
| name="drag_indicator" | ||
| :size="16" | ||
| class="shrink-0 cursor-grab text-slate-400 active:cursor-grabbing" | ||
| /> | ||
| <MaterialIcon | ||
| :name="bottomNavItems[idx + 1]?.icon ?? 'help'" | ||
| :size="22" | ||
| class="shrink-0 text-slate-600 dark:text-gray-300" | ||
| /> | ||
| <span | ||
| class="flex-1 text-sm font-medium text-slate-800 dark:text-gray-200" | ||
| >{{ bottomNavItems[idx + 1]?.label }}</span | ||
| > | ||
| <button | ||
| type="button" | ||
| @click="handleRemove(href)" | ||
| :disabled="!canRemove" | ||
| class="flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-600 disabled:opacity-30 dark:hover:bg-rose-950/40" | ||
| aria-label="Remove from bottom bar" | ||
| > | ||
| <MaterialIcon name="close" :size="16" /> | ||
| </button> | ||
| </li> | ||
|
|
||
| <!-- Account pinned last --> | ||
| <li | ||
| class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 dark:border-gray-700 dark:bg-gray-800/60" | ||
| > | ||
| <MaterialIcon | ||
| name="lock" | ||
| :size="16" | ||
| class="shrink-0 text-slate-400" | ||
| /> | ||
| <MaterialIcon | ||
| :name="accountItem.icon" | ||
| :size="22" | ||
| class="shrink-0 text-slate-700 dark:text-gray-300" | ||
| /> | ||
| <span | ||
| class="flex-1 text-sm font-semibold text-slate-900 dark:text-gray-100" | ||
| >{{ accountItem.label }}</span | ||
| > | ||
| <span | ||
| class="rounded-full bg-slate-900 px-2 py-0.5 text-[10px] font-bold text-white dark:bg-white dark:text-slate-900" | ||
| >Pinned last</span | ||
| > | ||
| </li> | ||
| </ul> | ||
| <p | ||
| v-if="!canRemove" | ||
| class="mt-2 text-[11px] text-amber-600 dark:text-amber-400" | ||
| > | ||
| Minimum {{ MIN_TOTAL }} items — remove disabled. | ||
| </p> | ||
| <p | ||
| v-if="!canAdd" | ||
| class="mt-2 text-[11px] text-amber-600 dark:text-amber-400" | ||
| > | ||
| Maximum {{ MAX_TOTAL }} items — add disabled. | ||
| </p> | ||
| </div> | ||
|
|
||
| <!-- Available pool --> | ||
| <div class="mt-6"> | ||
| <p | ||
| class="mb-2 text-[11px] font-bold tracking-widest text-slate-400 uppercase dark:text-gray-500" | ||
| > | ||
| More items — tap to add to bottom bar | ||
| </p> | ||
| <div | ||
| v-if="availableItems.length === 0" | ||
| class="rounded-xl border border-dashed border-slate-200 p-4 text-center text-xs text-slate-500 dark:border-gray-700 dark:text-gray-400" | ||
| > | ||
| All items are already in your bottom bar. | ||
| </div> | ||
| <div v-else class="grid grid-cols-1 gap-2 sm:grid-cols-2"> | ||
| <button | ||
| v-for="item in availableItems" | ||
| :key="item.href" | ||
| type="button" | ||
| @click="handleAdd(item.href)" | ||
| :disabled="!canAdd" | ||
| class="flex items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-left text-sm font-medium transition hover:bg-slate-50 disabled:opacity-40 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700/60" | ||
| > | ||
| <MaterialIcon | ||
| :name="item.icon" | ||
| :size="22" | ||
| class="shrink-0 text-slate-500" | ||
| /> | ||
| <span class="flex-1 text-slate-700 dark:text-gray-300">{{ | ||
| item.label | ||
| }}</span> | ||
| <MaterialIcon | ||
| name="add" | ||
| :size="16" | ||
| class="shrink-0 text-indigo-600 dark:text-indigo-400" | ||
| /> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </template> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use the configured CSS import notation.
Stylelint rejects
url(...)in this@import. Use the quoted URL form.Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 Stylelint (17.14.0)
[error] 1-1: Expected "url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,1,0&display=swap')" to be "'https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,1,0&display=swap'" (import-notation)
(import-notation)
🤖 Prompt for AI Agents
Source: Linters/SAST tools