Skip to content

Commit 9746189

Browse files
authored
Merge pull request #23 from PureStackStudio/set-tab-mobile-select-optional
Make Tabs’ mobile select optional and reactive.
2 parents 038eabd + 115d575 commit 9746189

8 files changed

Lines changed: 206 additions & 15 deletions

File tree

‎frontend/purestack.studio/components/layout/tabs/playground.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface TabsPlayground {
3939
icons: Ref<boolean>
4040
header: Ref<boolean>
4141
teamDisabled: Ref<boolean>
42+
mobileSelect: Ref<boolean>
4243
tones: FormSelectOption[]
4344
variants: FormSelectOption[]
4445
modes: FormSelectOption[]
@@ -54,7 +55,7 @@ const tabsPlaygroundTemplate = html`<Flex direction="column">
5455
<Badge tone="accent" variant="surface">Live playground</Badge>
5556
</Flex>
5657
<Tabs id="tabs-playground-preview" group="tabs-playground-views" :selectedTab="selected"
57-
:tone="tone" :variant="shell" :tabVariant="controls" :variantMode="mode" ariaLabel="Project workspace">
58+
:tone="tone" :variant="shell" :tabVariant="controls" :variantMode="mode" :mobileSelect="mobileSelect" ariaLabel="Project workspace">
5859
<template #header>
5960
<Flex r-if="header" class="tabs__header" justify="between" align="center" wrap="true">
6061
<div><p class="text-eyebrow m-0">PureStack Studio</p><h3 class="mt-1 mb-0">Project workspace</h3></div>
@@ -94,6 +95,7 @@ const tabsPlaygroundTemplate = html`<Flex direction="column">
9495
<FormCheck id="tabs-disabled" label="Disable Team" :checked="teamDisabled"/>
9596
</Flex>
9697
</div>
98+
<FormCheck id="tabs-mobile-select" label="Use a select on mobile" :checked="mobileSelect"/>
9799
<Flex justify="between" align="center" wrap="true">
98100
<p class="text-muted m-0" role="status">Selected: <code id="tabs-selected">{{ selected }}</code></p>
99101
<Btn variant="outline" size="sm" @click="reset">Reset playground</Btn>
@@ -115,6 +117,7 @@ const tabsPlayground = defineComponent<TabsPlayground>(tabsPlaygroundTemplate, {
115117
const icons = ref(true)
116118
const header = ref(true)
117119
const teamDisabled = ref(true)
120+
const mobileSelect = ref(false)
118121
const refresh = () => {
119122
if (teamDisabled() && selected() === 'tabs-lab-team')
120123
selected('tabs-lab-overview')
@@ -132,6 +135,7 @@ const tabsPlayground = defineComponent<TabsPlayground>(tabsPlaygroundTemplate, {
132135
icons,
133136
header,
134137
teamDisabled,
138+
mobileSelect,
135139
refresh,
136140
tones: [
137141
'neutral',
@@ -180,6 +184,7 @@ const tabsPlayground = defineComponent<TabsPlayground>(tabsPlaygroundTemplate, {
180184
Tabs: {
181185
group: 'tabs-playground-views',
182186
selectedTab: selected(),
187+
mobileSelect: mobileSelect(),
183188
tone: tone(),
184189
variant: shell(),
185190
tabVariant: controls(),
@@ -204,6 +209,7 @@ const tabsPlayground = defineComponent<TabsPlayground>(tabsPlaygroundTemplate, {
204209
icons(true)
205210
header(true)
206211
teamDisabled(true)
212+
mobileSelect(false)
207213
refresh()
208214
},
209215
}

‎frontend/purestack.studio/components/layout/tabs/tabs.mdx‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ nav:
1818
<Grid columns="1" columnsMd="3">
1919
<Panel variant="outline" bodyClass="p-3"><p class="text-eyebrow mt-0">Selection</p><strong>One shared ref</strong><p class="text-muted mb-0">Connect the active pane to forms, buttons and the rest of your app.</p></Panel>
2020
<Panel variant="outline" bodyClass="p-3"><p class="text-eyebrow mt-0">State</p><strong>Panels stay mounted</strong><p class="text-muted mb-0">Switch views without discarding a draft or resetting an input.</p></Panel>
21-
<Panel variant="outline" bodyClass="p-3"><p class="text-eyebrow mt-0">Space</p><strong>Controls adapt</strong><p class="text-muted mb-0">Overflow on narrow containers. A native selector on mobile.</p></Panel>
21+
<Panel variant="outline" bodyClass="p-3"><p class="text-eyebrow mt-0">Space</p><strong>Controls adapt</strong><p class="text-muted mb-0">Overflow on narrow containers. An optional native selector on mobile.</p></Panel>
2222
</Grid>
2323

2424
## In context
@@ -127,7 +127,7 @@ const select = (id: string) => {
127127
}
128128
```
129129

130-
`refresh()` rebuilds the generated buttons and selector; it keeps the existing panel content. Scope it to the root you changed. Also refresh after inserting or removing panes. A ref assignment updates the underlying radio binding, but the current runtime does not watch every component prop automatically.
130+
`refresh()` rebuilds the generated buttons and selector; it keeps the existing panel content. Scope it to the root you changed. Also refresh after inserting or removing panes. A ref assignment updates the underlying radio binding, but the current runtime does not watch every component prop automatically. Changes to `mobileSelect` update the layout automatically and do not require a refresh.
131131

132132
Choose an enabled pane explicitly when disabling or removing the selected pane. This keeps the model and visible view aligned. The playground demonstrates that policy in its `refresh` action.
133133

@@ -157,9 +157,23 @@ Change the example's width without resizing the page. On desktop, the More tabs
157157
| --- | --- | --- |
158158
| Wide viewport, enough container space | Horizontal buttons | Choose Full available width |
159159
| Wide viewport, narrow container | Buttons plus More tabs | Choose 280px or 360px |
160-
| Viewport at or below the framework's `sm` breakpoint (640px) | Native select | Open the page on a phone or resize the browser |
160+
| Viewport at or below the framework's `sm` breakpoint (640px), with `mobileSelect` enabled | Native select | Enable Use a select on mobile in the playground, then resize the browser |
161+
| Mobile viewport with `mobileSelect` disabled (default) | Buttons plus More tabs when needed | Leave Use a select on mobile unchecked in the playground |
161162

162-
Container width determines overflow. **Viewport width** determines compact mode. A 280px component inside a desktop page still uses the overflow menu. Selection and panel state survive either transition.
163+
Container width determines overflow. When `mobileSelect` is enabled, **viewport width** determines compact mode. A 280px component inside a desktop page still uses the overflow menu. Selection and panel state survive either transition.
164+
165+
### Use a select on mobile
166+
167+
Tabs keep their buttons and overflow menu at every viewport width by default. Set `:mobileSelect="true"` on an individual Tabs to use a native select on mobile:
168+
169+
```html
170+
<Tabs :mobileSelect="true" selectedTab="mobile-overview">
171+
<TabPane id="mobile-overview" label="Overview">Overview content</TabPane>
172+
<TabPane id="mobile-details" label="Details">Details content</TabPane>
173+
</Tabs>
174+
```
175+
176+
The default is `false`. In a Regor app, bind a boolean ref with `:mobileSelect="mobileSelect"`; changing it switches between the mobile select and tab buttons without rebuilding the component or resetting its selected pane. The playground's Use a select on mobile checkbox demonstrates this.
163177

164178
## Header slots, overrides and nested groups
165179

@@ -206,7 +220,7 @@ All variant values are available in the playground: `none`, `solid`, `surface`,
206220
</div>
207221

208222
<nav class="api-index" aria-label="Tabs API">
209-
<a href="#tabs-group-api">group</a><a href="#tabs-selected-api">selectedTab</a><a href="#tabs-label-api">ariaLabel</a><a href="#tabs-tone-api">tone</a><a href="#tabs-variant-api">variants</a><a href="#pane-id-api">pane identity</a><a href="#pane-disabled-api">disabled</a><a href="#pane-style-api">pane styling</a><a href="#tabs-slots-api">slots</a><a href="#tabs-runtime-api">runtime</a>
223+
<a href="#tabs-group-api">group</a><a href="#tabs-selected-api">selectedTab</a><a href="#tabs-mobile-select-api">mobileSelect</a><a href="#tabs-label-api">ariaLabel</a><a href="#tabs-tone-api">tone</a><a href="#tabs-variant-api">variants</a><a href="#pane-id-api">pane identity</a><a href="#pane-disabled-api">disabled</a><a href="#pane-style-api">pane styling</a><a href="#tabs-slots-api">slots</a><a href="#tabs-runtime-api">runtime</a>
210224
</nav>
211225

212226
### Tabs properties
@@ -220,6 +234,11 @@ All variant values are available in the playground: `none`, `solid`, `surface`,
220234
<p>Set an enabled pane explicitly for predictable initial state. Without a valid checked pane, the enhanced runtime chooses the first enabled pane; the CSS-only fallback displays the first pane.</p>
221235
</ApiProperty>
222236

237+
<ApiProperty anchor="tabs-mobile-select-api" name="mobileSelect" valueType="RefOrValue&lt;boolean&gt;" defaultValue="false">
238+
<p>Set true to use a native select at viewport widths up to 640px. By default, Tabs retain their buttons and overflow menu on mobile.</p>
239+
<p>Bind a boolean ref to change this per instance. The browser runtime updates the layout automatically and preserves the selected pane; no refresh is needed for this prop.</p>
240+
</ApiProperty>
241+
223242
<ApiProperty anchor="tabs-label-api" name="ariaLabel" valueType="RefOrValue&lt;string&gt;" defaultValue="Tabs">
224243
<p>Accessible group name for the tab list and compact selector. Describe the views, such as Release workspace or Deployment environment.</p>
225244
</ApiProperty>
@@ -484,7 +503,7 @@ A text label, an optional icon, a disabled view, and label fallback to the ID ar
484503
| Selecting one group clears another | Give each Tabs instance a different group |
485504
| The wrong panel opens or labels target another example | Make every TabPane id document-unique |
486505
| The model changes but enhanced controls look stale | Refresh the affected root after the DOM update |
487-
| A narrow desktop demo does not use a select | Compact mode follows viewport width, not container width |
506+
| A narrow desktop demo does not use a select | Enable mobileSelect; compact mode follows viewport width, not container width |
488507
| A header appears below the generated controls | Add tabs__header to the header-slot wrapper |
489508
| A selected pane becomes disabled | Move selection to an enabled pane before refreshing |
490509

‎packages/ts-components/src/standard/tabs/tabs.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,85 @@ import { defineIconComponents } from '../icon/icon'
88
import { defineTabsComponents } from './tabs'
99

1010
describe('Tabs rendering', () => {
11+
it.each([
12+
[undefined, null],
13+
[true, 'true'],
14+
[false, 'false'],
15+
])(
16+
'renders the mobile select setting %s as %s',
17+
async (mobileSelect, expected) => {
18+
const cleanup = ensureDomGlobals()
19+
try {
20+
const binding =
21+
mobileSelect === undefined ? '' : `:mobileSelect="${mobileSelect}"`
22+
const html = await renderApp(
23+
`<Tabs ${binding}><TabPane id="overview" label="Overview">Content</TabPane></Tabs>`,
24+
{
25+
components: {
26+
...defineIconComponents(getSvgIcon),
27+
...defineTabsComponents(),
28+
},
29+
context: createTestContext({
30+
pageInfo: { relPath: 'tabs.mdx', urlPath: '/tabs/' },
31+
}),
32+
},
33+
)
34+
if (expected === null) {
35+
expect(html).not.toContain('data-mobile-select=')
36+
} else {
37+
expect(html).toContain(`data-mobile-select="${expected}"`)
38+
}
39+
} finally {
40+
cleanup()
41+
}
42+
},
43+
)
44+
45+
it('updates mobileSelect reactively per instance without changing selection', () => {
46+
const cleanupDom = createDom(
47+
'<html><body><div id="app"></div></body></html>',
48+
)
49+
const mobileSelect = ref(false)
50+
const selectedTab = ref('details')
51+
const app = createApp(
52+
{
53+
components: {
54+
...defineIconComponents(getSvgIcon),
55+
...defineTabsComponents(),
56+
},
57+
mobileSelect,
58+
selectedTab,
59+
},
60+
{
61+
selector: '#app',
62+
template: `<Tabs id="configurable-tabs" group="configurable" :mobileSelect="mobileSelect" :selectedTab="selectedTab">
63+
<TabPane id="overview" label="Overview">Overview</TabPane>
64+
<TabPane id="details" label="Details">Details</TabPane>
65+
</Tabs>
66+
<Tabs id="default-tabs" group="default"><TabPane id="other" label="Other">Other</TabPane></Tabs>`,
67+
},
68+
)
69+
try {
70+
const root = document.querySelector('#configurable-tabs')
71+
const other = document.querySelector('#default-tabs')
72+
expect(root?.getAttribute('data-mobile-select')).toBe('false')
73+
expect(other?.getAttribute('data-mobile-select')).toBeNull()
74+
75+
mobileSelect(true)
76+
expect(root?.getAttribute('data-mobile-select')).toBe('true')
77+
mobileSelect(false)
78+
expect(root?.getAttribute('data-mobile-select')).toBe('false')
79+
expect(other?.getAttribute('data-mobile-select')).toBeNull()
80+
expect(selectedTab()).toBe('details')
81+
expect(
82+
document.querySelector<HTMLInputElement>('#details')?.checked,
83+
).toBe(true)
84+
} finally {
85+
app.unbind()
86+
cleanupDom()
87+
}
88+
})
89+
1190
it('renders slotted tab content and active tab state', async () => {
1291
const cleanup = ensureDomGlobals()
1392
const components = {

‎packages/ts-components/src/standard/tabs/tabs.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class Tabs {
2727
group: string = defaultGroup
2828
ariaLabel?: RefOrValue<string>
2929
selectedTab?: RefOrValue<string>
30+
mobileSelect?: RefOrValue<boolean>
3031
tone?: RefOrValue<SemanticTone>
3132
variant?: RefOrValue<ComponentVariant>
3233
variantMode?: RefOrValue<ComponentVariantMode>
@@ -48,7 +49,7 @@ export interface TabPane {
4849
tabClasses?: ComputedRef<string>
4950
}
5051

51-
const tabsTemplate = html`<section class="tabs" :class="classes">
52+
const tabsTemplate = html`<section class="tabs" :class="classes" :data-mobile-select="mobileSelect">
5253
<slot name="header"></slot>
5354
<div class="tabs__list" role="tablist" :aria-label="ariaLabel || 'Tabs'">
5455
<slot></slot>
@@ -96,6 +97,7 @@ function defineTabsComponent() {
9697
'ariaLabel',
9798
'group',
9899
'selectedTab',
100+
'mobileSelect',
99101
'tone',
100102
'variant',
101103
'variantMode',
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { createDom } from '@purestack/ts-minidom'
2+
import { styleBuilder } from '@purestack/ts-style'
3+
import { beforeEach, describe, expect, it } from 'vitest'
4+
import { registerTabsStyles } from './tabsStyle'
5+
6+
function tabsOwnerId(control: Element) {
7+
let parent = control.parentElement
8+
while (parent) {
9+
if (parent.classList.contains('tabs')) return parent.id
10+
parent = parent.parentElement
11+
}
12+
}
13+
14+
describe('nested Tabs responsive styles', () => {
15+
beforeEach(() => {
16+
styleBuilder.reset()
17+
})
18+
19+
it.each(['light', 'dark'] as const)(
20+
'keeps a compact parent from changing its nested controls in the %s theme',
21+
(theme) => {
22+
const cleanup = createDom(`<html><body>
23+
<section id="outer" class="tabs tabs--enhanced tabs--compact">
24+
<div class="tabs__tabs-row"></div>
25+
<div class="tabs__select-wrap"></div>
26+
<div class="tabs__list"><div class="tabs__item">
27+
<label class="tabs__tab">Outer tab</label>
28+
<section class="tabs__panel">
29+
<section id="inner" class="tabs tabs--enhanced" data-mobile-select="false">
30+
<div class="tabs__tabs-row"></div>
31+
<div class="tabs__select-wrap"></div>
32+
<div class="tabs__list"><div class="tabs__item">
33+
<label class="tabs__tab">Inner tab</label>
34+
</div></div>
35+
</section>
36+
<section id="static" class="tabs">
37+
<div class="tabs__list"><div class="tabs__item">
38+
<label class="tabs__tab">Static fallback tab</label>
39+
</div></div>
40+
</section>
41+
</section>
42+
</div></div>
43+
</section>
44+
</body></html>`)
45+
try {
46+
registerTabsStyles()
47+
const rules = [...styleBuilder.get(theme).children.values()]
48+
const compactRules = rules.filter((rule) =>
49+
rule.selector.includes('.tabs--compact'),
50+
)
51+
expect(compactRules.length).toBeGreaterThan(0)
52+
for (const rule of compactRules) {
53+
const matched = [...document.querySelectorAll(rule.selector)]
54+
expect(matched.length).toBeGreaterThan(0)
55+
for (const control of matched) {
56+
expect(tabsOwnerId(control)).toBe('outer')
57+
}
58+
}
59+
60+
const labelRule = rules.find(
61+
(rule) =>
62+
rule.selector.includes('.tabs--enhanced') &&
63+
rule.selector.endsWith('.tabs__tab'),
64+
)
65+
expect(labelRule).toBeDefined()
66+
const labels = [...document.querySelectorAll(labelRule?.selector ?? '')]
67+
expect(labels).toHaveLength(2)
68+
expect(labels.map(tabsOwnerId)).toEqual(['outer', 'inner'])
69+
} finally {
70+
cleanup()
71+
}
72+
},
73+
)
74+
})

‎packages/ts-components/src/standard/tabs/tabsStyle.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -215,17 +215,21 @@ function registerTabsPanelStyles(theme: ThemeMode) {
215215
}
216216

217217
function registerTabsResponsiveStyles(theme: ThemeMode) {
218-
styleBuilder.select('.tabs--enhanced .tabs__tabs-row', theme).display('flex')
218+
styleBuilder
219+
.select('.tabs--enhanced > .tabs__tabs-row', theme)
220+
.display('flex')
219221

220222
styleBuilder
221-
.select('.tabs--compact .tabs__select-wrap', theme)
223+
.select('.tabs--compact > .tabs__select-wrap', theme)
222224
.display('block')
223225

224-
styleBuilder.select('.tabs--enhanced .tabs__tab', theme).display('none')
226+
styleBuilder
227+
.select('.tabs--enhanced > .tabs__list > .tabs__item > .tabs__tab', theme)
228+
.display('none')
225229

226-
styleBuilder.select('.tabs--compact .tabs__tabs-row', theme).display('none')
230+
styleBuilder.select('.tabs--compact > .tabs__tabs-row', theme).display('none')
227231

228232
styleBuilder
229-
.select('.tabs--compact .tabs__list', theme)
233+
.select('.tabs--compact > .tabs__list', theme)
230234
.gridTemplateColumns('1fr')
231235
}

0 commit comments

Comments
 (0)