mirror of
https://github.com/filamentphp/filament.git
synced 2026-09-24 15:42:09 +08:00
feat: add new horizontal tabs navigation user experience (#18310)
* feat: Add none scrollable horizontal tab user experience
Introduces `isHorizontal` and `isScrollable` properties to the Tabs component, allowing configuration of tab orientation and scrollability. Updates Blade, JS, and Alpine logic to support scrollable tabs with dropdown overflow handling and responsive behavior.
* style: run composer cs
* Refactor Tabs component and update related styles
Refactored the Tabs component across PHP, Blade, and JS implementations to improve maintainability and consistency. Updated related CSS for dropdown list items to enhance UI appearance. These changes ensure better integration and user experience for tabbed interfaces.
* Refactor tabs dropdown logic for improved visibility
Replaces the scrollable tabs logic with a more robust dropdown visibility calculation. Updates Alpine component, Blade template, and supporting JS to use new withinDropdownMounted and isDropDownButtonVisible properties, ensuring correct tab visibility and dropdown trigger behavior. Removes unused tabIndex and related code, and cleans up CSS.
* chore: fix code style
* chore: fix code style
* Add documentation for horizontal tabs usage
Added a new section explaining how to use horizontal tabs, including details about the default scrollable behavior and the `horizontal()` method with its `scrollable` parameter.
* Document horizontal tabs overflow behavior
Added explanation for how non-scrollable horizontal tabs handle overflow by grouping excess tabs into a dropdown when space is limited.
* chore: fix code style
* clean up
* Update tabs.blade.php
* more robust calculation
* chore: fix code style
* Revert "chore: fix code style"
This reverts commit 3acc6fe781.
* refactor and support badges
* refactor php
* Update tabs.blade.php
* Update tabs.js
* Update tabs.blade.php
* screenshots
---------
Co-authored-by: Abdulmajeed-Jamaan <Abdulmajeed-Jamaan@users.noreply.github.com>
Co-authored-by: Dan Harrin <git@danharrin.com>
Co-authored-by: danharrin <danharrin@users.noreply.github.com>
This commit is contained in:
co-authored by
Abdulmajeed-Jamaan
Dan Harrin
danharrin
parent
8f9380a85e
commit
cdafd6e52b
@@ -2,7 +2,7 @@ APP_NAME=Laravel
|
||||
APP_ENV=ci
|
||||
APP_KEY=base64:/RjsPfTKUG/cV+SQjSOfCfgFPZ85Zmwywiz5yevEZ0s=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://127.0.0.1::8000
|
||||
APP_URL=http://127.0.0.1:8000
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
|
||||
@@ -284,6 +284,28 @@ class LayoutDemo extends Component implements HasActions, HasSchemas
|
||||
])
|
||||
->vertical(),
|
||||
]),
|
||||
Group::make()
|
||||
->id('tabsNotScrollable')
|
||||
->extraAttributes([
|
||||
'class' => 'p-16 max-w-xl',
|
||||
])
|
||||
->schema([
|
||||
Tabs::make('Tabs')
|
||||
->statePath('tabsNotScrollable')
|
||||
->schema([
|
||||
Tab::make('Tab 1')
|
||||
->schema([
|
||||
TextInput::make('field'),
|
||||
]),
|
||||
Tab::make('Tab 2'),
|
||||
Tab::make('Tab 3'),
|
||||
Tab::make('Tab 4'),
|
||||
Tab::make('Tab 5'),
|
||||
Tab::make('Tab 6'),
|
||||
Tab::make('Tab 7'),
|
||||
])
|
||||
->scrollable(false),
|
||||
]),
|
||||
Group::make()
|
||||
->id('wizard')
|
||||
->extraAttributes([
|
||||
|
||||
Generated
+882
-691
File diff suppressed because it is too large
Load Diff
Generated
+525
-493
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -1610,6 +1610,15 @@ export default {
|
||||
deviceScaleFactor: 3,
|
||||
},
|
||||
},
|
||||
'schemas/layout/tabs/not-scrollable': {
|
||||
url: 'schemas/layout',
|
||||
selector: '#tabsNotScrollable',
|
||||
viewport: {
|
||||
width: 1920,
|
||||
height: 640,
|
||||
deviceScaleFactor: 3,
|
||||
},
|
||||
},
|
||||
'schemas/layout/wizard/simple': {
|
||||
url: 'schemas/layout',
|
||||
selector: '#wizard',
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
function u({activeTab:a,isTabPersistedInQueryString:e,livewireId:h,tab:o,tabQueryStringKey:s}){return{tab:o,init(){let t=this.getTabs(),i=new URLSearchParams(window.location.search);e&&i.has(s)&&t.includes(i.get(s))&&(this.tab=i.get(s)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[a-1]),Livewire.hook("commit",({component:r,commit:f,succeed:c,fail:l,respond:b})=>{c(({snapshot:d,effect:m})=>{this.$nextTick(()=>{if(r.id!==h)return;let n=this.getTabs();n.includes(this.tab)||(this.tab=n[a-1]??this.tab)})})})},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!e)return;let t=new URL(window.location.href);t.searchParams.set(s,this.tab),history.replaceState(null,document.title,t.toString())}}}export{u as default};
|
||||
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:p,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let s=this.getTabs();s.includes(this.tab)||(this.tab=s[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let s=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),o=Math.ceil(s.clientWidth),l=a?Math.ceil(a.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;i<t.length;i++){let s=u.slice(0,i+1).reduce((b,y)=>b+y,0),a=i*n,o=p.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(b=>b.total)):0,D=l?r+W+d+h+n:0;if(s+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),s=this.findOverflowIndex(n,r,h,p,i,u);n.forEach((a,o)=>a.style.display=d[o]),s!==-1&&(this.withinDropdownIndex=s),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};
|
||||
|
||||
@@ -177,6 +177,26 @@ Tabs::make('Tabs')
|
||||
|
||||
<UtilityInjection set="schemaComponents" version="4.x">As well as allowing a static value, the `columns()` method also accepts a function to dynamically calculate it. You can inject various utilities into the function as parameters.</UtilityInjection>
|
||||
|
||||
## Disabling scrollable tabs
|
||||
|
||||
Tabs are rendered horizontally by default, and are scrollable when they exceed the available width.
|
||||
|
||||
You may disable scrolling using the `scrollable(false)` method:
|
||||
|
||||
```php
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
|
||||
Tabs::make('Tabs')
|
||||
->tabs([
|
||||
// ...
|
||||
])
|
||||
->scrollable(false)
|
||||
```
|
||||
|
||||
When tabs are not scrollable, the component automatically detects the available width. If not all tabs can fit, a dropdown button will appear. Any tabs that exceed the available width will be grouped inside this dropdown automatically.
|
||||
|
||||
<AutoScreenshot name="schemas/layout/tabs/not-scrollable" alt="Non-scrollable tabs with overflow dropdown" version="4.x" />
|
||||
|
||||
## Using vertical tabs
|
||||
|
||||
You can render the tabs vertically by using the `vertical()` method:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
.fi-sc-tabs {
|
||||
@apply flex flex-col;
|
||||
|
||||
& .fi-tabs.fi-invisible {
|
||||
@apply invisible;
|
||||
}
|
||||
|
||||
& .fi-sc-tabs-tab {
|
||||
@apply outline-hidden;
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
export default function tabsSchemaComponent({
|
||||
activeTab,
|
||||
isScrollable,
|
||||
isTabPersistedInQueryString,
|
||||
livewireId,
|
||||
tab,
|
||||
tabQueryStringKey,
|
||||
}) {
|
||||
return {
|
||||
boundResizeHandler: null,
|
||||
isScrollable,
|
||||
resizeDebounceTimer: null,
|
||||
tab,
|
||||
withinDropdownIndex: null,
|
||||
withinDropdownMounted: false,
|
||||
|
||||
init() {
|
||||
const tabs = this.getTabs()
|
||||
|
||||
const queryString = new URLSearchParams(window.location.search)
|
||||
|
||||
if (
|
||||
@@ -45,14 +50,132 @@ export default function tabsSchemaComponent({
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
if (!isScrollable) {
|
||||
this.boundResizeHandler =
|
||||
this.debouncedUpdateTabsWithinDropdown.bind(this)
|
||||
|
||||
window.addEventListener('resize', this.boundResizeHandler)
|
||||
|
||||
this.updateTabsWithinDropdown()
|
||||
}
|
||||
},
|
||||
|
||||
calculateAvailableWidth(containerEl) {
|
||||
const styles = window.getComputedStyle(containerEl)
|
||||
|
||||
return (
|
||||
Math.floor(containerEl.clientWidth) -
|
||||
Math.ceil(parseFloat(styles.paddingLeft)) * 2
|
||||
)
|
||||
},
|
||||
|
||||
calculateContainerGap(containerEl) {
|
||||
const styles = window.getComputedStyle(containerEl)
|
||||
|
||||
return Math.ceil(parseFloat(styles.columnGap))
|
||||
},
|
||||
|
||||
calculateDropdownIconWidth(triggerEl) {
|
||||
const iconEl = triggerEl.querySelector('.fi-icon')
|
||||
|
||||
return Math.ceil(iconEl.clientWidth)
|
||||
},
|
||||
|
||||
calculateTabItemGap(tabEl) {
|
||||
const styles = window.getComputedStyle(tabEl)
|
||||
|
||||
return Math.ceil(parseFloat(styles.columnGap) || 8)
|
||||
},
|
||||
|
||||
calculateTabItemPadding(tabEl) {
|
||||
const styles = window.getComputedStyle(tabEl)
|
||||
|
||||
return (
|
||||
Math.ceil(parseFloat(styles.paddingLeft)) +
|
||||
Math.ceil(parseFloat(styles.paddingRight))
|
||||
)
|
||||
},
|
||||
|
||||
findOverflowIndex(
|
||||
tabElements,
|
||||
availableWidth,
|
||||
containerGap,
|
||||
tabItemGap,
|
||||
tabItemPadding,
|
||||
dropdownIconWidth,
|
||||
) {
|
||||
const tabWidths = tabElements.map((el) => Math.ceil(el.clientWidth))
|
||||
|
||||
const tabContentWidths = tabElements.map((el) => {
|
||||
const labelEl = el.querySelector('.fi-tabs-item-label')
|
||||
const badgeEl = el.querySelector('.fi-badge')
|
||||
|
||||
const labelWidth = Math.ceil(labelEl.clientWidth)
|
||||
const badgeWidth = badgeEl ? Math.ceil(badgeEl.clientWidth) : 0
|
||||
|
||||
return {
|
||||
label: labelWidth,
|
||||
badge: badgeWidth,
|
||||
total:
|
||||
labelWidth +
|
||||
(badgeWidth > 0 ? tabItemGap + badgeWidth : 0),
|
||||
}
|
||||
})
|
||||
|
||||
for (let i = 0; i < tabElements.length; i++) {
|
||||
const visibleTabsWidth = tabWidths
|
||||
.slice(0, i + 1)
|
||||
.reduce((sum, w) => sum + w, 0)
|
||||
|
||||
const gapsBetweenVisibleTabs = i * containerGap
|
||||
|
||||
const collapsedContents = tabContentWidths.slice(i + 1)
|
||||
const hasCollapsedTabs = collapsedContents.length > 0
|
||||
|
||||
const widestCollapsedContent = hasCollapsedTabs
|
||||
? Math.max(...collapsedContents.map((c) => c.total))
|
||||
: 0
|
||||
|
||||
const triggerWidth = hasCollapsedTabs
|
||||
? tabItemPadding +
|
||||
widestCollapsedContent +
|
||||
tabItemGap +
|
||||
dropdownIconWidth +
|
||||
containerGap
|
||||
: 0
|
||||
|
||||
const totalWidth =
|
||||
visibleTabsWidth + gapsBetweenVisibleTabs + triggerWidth
|
||||
|
||||
if (totalWidth > availableWidth) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
},
|
||||
|
||||
get isDropdownButtonVisible() {
|
||||
if (!this.withinDropdownMounted) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.withinDropdownIndex === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const activeTabIndex = this.getTabs().findIndex(
|
||||
(tab) => tab === this.tab,
|
||||
)
|
||||
|
||||
return activeTabIndex < this.withinDropdownIndex
|
||||
},
|
||||
|
||||
getTabs() {
|
||||
if (!this.$refs.tabsData) {
|
||||
return []
|
||||
}
|
||||
|
||||
return JSON.parse(this.$refs.tabsData.value)
|
||||
return this.$refs.tabsData
|
||||
? JSON.parse(this.$refs.tabsData.value)
|
||||
: []
|
||||
},
|
||||
|
||||
updateQueryString() {
|
||||
@@ -65,5 +188,68 @@ export default function tabsSchemaComponent({
|
||||
|
||||
history.replaceState(null, document.title, url.toString())
|
||||
},
|
||||
|
||||
debouncedUpdateTabsWithinDropdown() {
|
||||
clearTimeout(this.resizeDebounceTimer)
|
||||
|
||||
this.resizeDebounceTimer = setTimeout(
|
||||
() => this.updateTabsWithinDropdown(),
|
||||
150,
|
||||
)
|
||||
},
|
||||
|
||||
async updateTabsWithinDropdown() {
|
||||
this.withinDropdownIndex = null
|
||||
this.withinDropdownMounted = false
|
||||
|
||||
await this.$nextTick()
|
||||
|
||||
const containerEl = this.$el.querySelector('.fi-tabs')
|
||||
const triggerEl = containerEl.querySelector(
|
||||
'.fi-tabs-item:last-child',
|
||||
)
|
||||
const tabElements = Array.from(containerEl.children).slice(0, -1)
|
||||
|
||||
// Force all tabs visible for accurate measurement
|
||||
const originalStyles = tabElements.map((el) => el.style.display)
|
||||
tabElements.forEach((el) => (el.style.display = ''))
|
||||
|
||||
// Force reflow to ensure dimensions are calculated
|
||||
containerEl.offsetHeight
|
||||
|
||||
const availableWidth = this.calculateAvailableWidth(containerEl)
|
||||
const containerGap = this.calculateContainerGap(containerEl)
|
||||
const dropdownIconWidth = this.calculateDropdownIconWidth(triggerEl)
|
||||
const tabItemGap = this.calculateTabItemGap(tabElements[0])
|
||||
const tabItemPadding = this.calculateTabItemPadding(tabElements[0])
|
||||
|
||||
const overflowIndex = this.findOverflowIndex(
|
||||
tabElements,
|
||||
availableWidth,
|
||||
containerGap,
|
||||
tabItemGap,
|
||||
tabItemPadding,
|
||||
dropdownIconWidth,
|
||||
)
|
||||
|
||||
// Restore original display styles
|
||||
tabElements.forEach(
|
||||
(el, i) => (el.style.display = originalStyles[i]),
|
||||
)
|
||||
|
||||
if (overflowIndex !== -1) {
|
||||
this.withinDropdownIndex = overflowIndex
|
||||
}
|
||||
|
||||
this.withinDropdownMounted = true
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.boundResizeHandler) {
|
||||
window.removeEventListener('resize', this.boundResizeHandler)
|
||||
}
|
||||
|
||||
clearTimeout(this.resizeDebounceTimer)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,57 @@
|
||||
@php
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\View\SchemaIconAlias;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
|
||||
$activeTab = $getActiveTab();
|
||||
$id = $getId();
|
||||
$isContained = $isContained();
|
||||
$isScrollable = $isScrollable();
|
||||
$isVertical = $isVertical();
|
||||
$label = $getLabel();
|
||||
$livewireProperty = $getLivewireProperty();
|
||||
$renderHookScopes = $getRenderHookScopes();
|
||||
$id = $getId();
|
||||
$tabs = $getChildSchema()->getComponents();
|
||||
|
||||
$getTabVisibilityJs = function (Tab $tab, ?int $index = null, ?string $mode = null) use ($isScrollable): ?string {
|
||||
$hiddenJs = $tab->getHiddenJs();
|
||||
$visibleJs = $tab->getVisibleJs();
|
||||
|
||||
$baseJs = match ([filled($hiddenJs), filled($visibleJs)]) {
|
||||
[true, true] => "(! ({$hiddenJs})) && ({$visibleJs})",
|
||||
[true, false] => "! ({$hiddenJs})",
|
||||
[false, true] => $visibleJs,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($isScrollable || $index === null || $mode === null) {
|
||||
return $baseJs;
|
||||
}
|
||||
|
||||
$tabKey = $tab->getKey(isAbsolute: false);
|
||||
|
||||
$dropdownJs = match ($mode) {
|
||||
'inline' => "(!withinDropdownMounted || withinDropdownIndex === null || {$index} < withinDropdownIndex)",
|
||||
'trigger' => "(withinDropdownMounted && withinDropdownIndex !== null && {$index} >= withinDropdownIndex && '{$tabKey}' === tab)",
|
||||
default => null,
|
||||
};
|
||||
|
||||
return $baseJs ? "{$baseJs} && {$dropdownJs}" : $dropdownJs;
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if (blank($livewireProperty))
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('tabs', 'filament/schemas') }}"
|
||||
x-data="tabsSchemaComponent({
|
||||
activeTab: @js($activeTab),
|
||||
isScrollable: @js($isScrollable),
|
||||
isTabPersistedInQueryString: @js($isTabPersistedInQueryString()),
|
||||
livewireId: @js($this->getId()),
|
||||
tab: @if ($isTabPersisted() && filled($id)) $persist(null).as(@js($id)) @else @js(null) @endif,
|
||||
tabQueryStringKey: @js($getTabQueryStringKey()),
|
||||
})"
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('tabs', 'filament/schemas') }}"
|
||||
wire:ignore.self
|
||||
{{
|
||||
$attributes
|
||||
@@ -39,13 +70,7 @@
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
value="{{
|
||||
collect($getChildSchema()->getComponents())
|
||||
->filter(static fn (Tab $tab): bool => $tab->isVisible())
|
||||
->map(static fn (Tab $tab) => $tab->getKey(isAbsolute: false))
|
||||
->values()
|
||||
->toJson()
|
||||
}}"
|
||||
value="{{ collect($tabs)->filter(static fn (Tab $tab): bool => $tab->isVisible())->map(static fn (Tab $tab) => $tab->getKey(isAbsolute: false))->values()->toJson() }}"
|
||||
x-ref="tabsData"
|
||||
/>
|
||||
|
||||
@@ -54,69 +79,126 @@
|
||||
:label="$label"
|
||||
:vertical="$isVertical"
|
||||
x-cloak
|
||||
:x-bind:class="! $isScrollable ? '{ \'fi-invisible\': ! withinDropdownMounted }' : null"
|
||||
>
|
||||
@foreach ($getStartRenderHooks() as $startRenderHook)
|
||||
{{ \Filament\Support\Facades\FilamentView::renderHook($startRenderHook, scopes: $renderHookScopes) }}
|
||||
@endforeach
|
||||
|
||||
@foreach ($getChildSchema()->getComponents() as $tab)
|
||||
@foreach ($tabs as $index => $tab)
|
||||
@php
|
||||
$tabKey = $tab->getKey(isAbsolute: false);
|
||||
$tabBadge = $tab->getBadge();
|
||||
$tabBadgeColor = $tab->getBadgeColor();
|
||||
$tabBadgeIcon = $tab->getBadgeIcon();
|
||||
$tabBadgeIconPosition = $tab->getBadgeIconPosition();
|
||||
$tabBadgeTooltip = $tab->getBadgeTooltip();
|
||||
$tabExtraAttributeBag = $tab->getExtraAttributeBag();
|
||||
$tabIcon = $tab->getIcon();
|
||||
$tabIconPosition = $tab->getIconPosition();
|
||||
$tabExtraAttributeBag = $tab->getExtraAttributeBag();
|
||||
$tabHiddenJs = $tab->getHiddenJs();
|
||||
$tabVisibleJs = $tab->getVisibleJs();
|
||||
$tabVisibilityJs = match ([filled($tabHiddenJs), filled($tabVisibleJs)]) {
|
||||
[true, true] => "(! ({$tabHiddenJs})) && ({$tabVisibleJs})",
|
||||
[true, false] => "! ({$tabHiddenJs})",
|
||||
[false, true] => $tabVisibleJs,
|
||||
default => null,
|
||||
};
|
||||
$tabKey = $tab->getKey(isAbsolute: false);
|
||||
$tabLabel = $tab->getLabel();
|
||||
$tabVisibilityJs = $getTabVisibilityJs($tab, $index, 'inline');
|
||||
@endphp
|
||||
|
||||
<x-filament::tabs.item
|
||||
:alpine-active="'tab === \'' . $tabKey . '\''"
|
||||
:attributes="$tabExtraAttributeBag"
|
||||
:badge="$tabBadge"
|
||||
:badge-color="$tabBadgeColor"
|
||||
:badge-icon="$tabBadgeIcon"
|
||||
:badge-icon-position="$tabBadgeIconPosition"
|
||||
:badge-tooltip="$tabBadgeTooltip"
|
||||
:data-tab-key="$tabKey"
|
||||
:icon="$tabIcon"
|
||||
:icon-position="$tabIconPosition"
|
||||
:x-cloak="$tabVisibilityJs !== null"
|
||||
:x-on:click="'tab = \'' . $tabKey . '\''"
|
||||
:x-show="$tabVisibilityJs"
|
||||
:x-cloak="$tabVisibilityJs !== null"
|
||||
:attributes="$tabExtraAttributeBag"
|
||||
>
|
||||
{{ $tab->getLabel() }}
|
||||
{{ $tabLabel }}
|
||||
</x-filament::tabs.item>
|
||||
@endforeach
|
||||
|
||||
@if (! $isScrollable)
|
||||
<x-filament::dropdown
|
||||
:placement="__('filament-panels::layout.direction') === 'ltr' ? 'bottom-start' : 'bottom-end'"
|
||||
>
|
||||
<x-slot name="trigger">
|
||||
@foreach ($tabs as $index => $tab)
|
||||
@php
|
||||
$tabBadge = $tab->getBadge();
|
||||
$tabBadgeColor = $tab->getBadgeColor();
|
||||
$tabBadgeTooltip = $tab->getBadgeTooltip();
|
||||
$tabExtraAttributeBag = $tab->getExtraAttributeBag();
|
||||
$tabKey = $tab->getKey(isAbsolute: false);
|
||||
$tabLabel = $tab->getLabel();
|
||||
$tabVisibilityJs = $getTabVisibilityJs($tab, $index, 'trigger');
|
||||
@endphp
|
||||
|
||||
<x-filament::tabs.item
|
||||
:alpine-active="'tab === \'' . $tabKey . '\''"
|
||||
:attributes="$tabExtraAttributeBag"
|
||||
:badge="$tabBadge"
|
||||
:badge-color="$tabBadgeColor"
|
||||
:badge-tooltip="$tabBadgeTooltip"
|
||||
:icon="Heroicon::ChevronDown"
|
||||
:icon-alias="SchemaIconAlias::COMPONENTS_TABS_DROPDOWN_TRIGGER_BUTTON"
|
||||
:x-cloak="$tabVisibilityJs !== null"
|
||||
:x-show="$tabVisibilityJs"
|
||||
>
|
||||
{{ $tabLabel }}
|
||||
</x-filament::tabs.item>
|
||||
@endforeach
|
||||
|
||||
<x-filament::tabs.item x-show="isDropdownButtonVisible">
|
||||
{{
|
||||
\Filament\Support\generate_icon_html(
|
||||
Heroicon::EllipsisHorizontal,
|
||||
alias: SchemaIconAlias::COMPONENTS_TABS_MORE_TABS_BUTTON,
|
||||
)
|
||||
}}
|
||||
</x-filament::tabs.item>
|
||||
</x-slot>
|
||||
|
||||
<x-filament::dropdown.list>
|
||||
@foreach ($tabs as $index => $tab)
|
||||
@php
|
||||
$tabBadge = $tab->getBadge();
|
||||
$tabBadgeColor = $tab->getBadgeColor();
|
||||
$tabBadgeTooltip = $tab->getBadgeTooltip();
|
||||
$tabIcon = $tab->getIcon();
|
||||
$tabKey = $tab->getKey(isAbsolute: false);
|
||||
$tabLabel = $tab->getLabel();
|
||||
@endphp
|
||||
|
||||
<x-filament::dropdown.list.item
|
||||
:badge="$tabBadge"
|
||||
:badge-color="$tabBadgeColor"
|
||||
:badge-tooltip="$tabBadgeTooltip"
|
||||
:icon="$tabIcon"
|
||||
x-bind:class="{ 'fi-selected': tab === '{{ $tabKey }}' }"
|
||||
:x-on:click="'tab = \'' . $tabKey . '\'; close($event);'"
|
||||
:x-show="$index . ' >= withinDropdownIndex'"
|
||||
>
|
||||
{{ $tabLabel }}
|
||||
</x-filament::dropdown.list.item>
|
||||
@endforeach
|
||||
</x-filament::dropdown.list>
|
||||
</x-filament::dropdown>
|
||||
@endif
|
||||
|
||||
@foreach ($getEndRenderHooks() as $endRenderHook)
|
||||
{{ \Filament\Support\Facades\FilamentView::renderHook($endRenderHook, scopes: $renderHookScopes) }}
|
||||
@endforeach
|
||||
</x-filament::tabs>
|
||||
|
||||
@foreach ($getChildSchema()->getComponents() as $tab)
|
||||
@foreach ($tabs as $tab)
|
||||
@php
|
||||
$tabHiddenJs = $tab->getHiddenJs();
|
||||
$tabVisibleJs = $tab->getVisibleJs();
|
||||
$tabVisibilityJs = match ([filled($tabHiddenJs), filled($tabVisibleJs)]) {
|
||||
[true, true] => "(! ({$tabHiddenJs})) && ({$tabVisibleJs})",
|
||||
[true, false] => "! ({$tabHiddenJs})",
|
||||
[false, true] => $tabVisibleJs,
|
||||
default => null,
|
||||
};
|
||||
$tabVisibilityJs = $getTabVisibilityJs($tab);
|
||||
@endphp
|
||||
|
||||
@if ($tabVisibilityJs)
|
||||
<div x-show="{!! $tabVisibilityJs !!}" x-cloak>
|
||||
<div x-cloak x-show="{!! $tabVisibilityJs !!}">
|
||||
{{ $tab }}
|
||||
</div>
|
||||
@else
|
||||
@@ -160,14 +242,16 @@
|
||||
$tabBadgeIcon = $tab->getBadgeIcon();
|
||||
$tabBadgeIconPosition = $tab->getBadgeIconPosition();
|
||||
$tabBadgeTooltip = $tab->getBadgeTooltip();
|
||||
$tabExtraAttributeBag = $tab->getExtraAttributeBag();
|
||||
$tabIcon = $tab->getIcon();
|
||||
$tabIconPosition = $tab->getIconPosition();
|
||||
$tabExtraAttributeBag = $tab->getExtraAttributeBag();
|
||||
$tabKey = strval($tabKey);
|
||||
$tabLabel = $tab->getLabel() ?? $this->generateTabLabel($tabKey);
|
||||
@endphp
|
||||
|
||||
<x-filament::tabs.item
|
||||
:active="$activeTab === $tabKey"
|
||||
:attributes="$tabExtraAttributeBag"
|
||||
:badge="$tabBadge"
|
||||
:badge-color="$tabBadgeColor"
|
||||
:badge-icon="$tabBadgeIcon"
|
||||
@@ -176,9 +260,8 @@
|
||||
:icon="$tabIcon"
|
||||
:icon-position="$tabIconPosition"
|
||||
:wire:click="'$set(\'' . $livewireProperty . '\', ' . (filled($tabKey) ? ('\'' . $tabKey . '\'') : 'null') . ')'"
|
||||
:attributes="$tabExtraAttributeBag"
|
||||
>
|
||||
{{ $tab->getLabel() ?? $this->generateTabLabel($tabKey) }}
|
||||
{{ $tabLabel }}
|
||||
</x-filament::tabs.item>
|
||||
@endforeach
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ class Tabs extends Component
|
||||
|
||||
protected string | Closure | null $livewireProperty = null;
|
||||
|
||||
protected bool | Closure $isScrollable = true;
|
||||
|
||||
protected bool | Closure $isVertical = false;
|
||||
|
||||
final public function __construct(string | Htmlable | Closure | null $label = null)
|
||||
@@ -184,6 +186,18 @@ class Tabs extends Component
|
||||
return $this->evaluate($this->livewireProperty);
|
||||
}
|
||||
|
||||
public function scrollable(bool | Closure $condition = true): static
|
||||
{
|
||||
$this->isScrollable = $condition;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isScrollable(): bool
|
||||
{
|
||||
return (bool) $this->evaluate($this->isScrollable);
|
||||
}
|
||||
|
||||
public function vertical(bool | Closure $condition = true): static
|
||||
{
|
||||
$this->isVertical = $condition;
|
||||
|
||||
@@ -4,5 +4,9 @@ namespace Filament\Schemas\View;
|
||||
|
||||
class SchemaIconAlias
|
||||
{
|
||||
const COMPONENTS_WIZARD_COMPLETED_STEP = 'schema::components.wizard.completed-step';
|
||||
public const COMPONENTS_TABS_DROPDOWN_TRIGGER_BUTTON = 'schema::components.tabs.dropdown-trigger-button';
|
||||
|
||||
public const COMPONENTS_TABS_MORE_TABS_BUTTON = 'schema::components.tabs.more-tabs-button';
|
||||
|
||||
public const COMPONENTS_WIZARD_COMPLETED_STEP = 'schema::components.wizard.completed-step';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user