diff --git a/apps/playground/app/dashboard/page.tsx b/apps/playground/app/dashboard/page.tsx index 273a87a1..e8d4572c 100644 --- a/apps/playground/app/dashboard/page.tsx +++ b/apps/playground/app/dashboard/page.tsx @@ -1,3 +1,4 @@ +'use client'; import { AccessibilityIcon, CameraIcon, @@ -43,6 +44,8 @@ import { PopoverTrigger, Separator, Strong, + TabsNavLink, + TabsNavRoot, Text, TextArea, TextFieldInput, @@ -174,10 +177,14 @@ const WhopSVG = () => { ); }; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; import { users } from '../demo/users'; import styles from './page.module.css'; export default function Demo() { + const pathname = usePathname(); + return ( @@ -330,6 +337,14 @@ export default function Demo() {
+ + + Dashboard + + + Demo + + ; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args + +export const Default: Story = { + render: (args) => ( +
+ + + Account + + Documents + Settings + +
+ ), +}; diff --git a/packages/frosted-ui/package.json b/packages/frosted-ui/package.json index 046ba0b2..c7319a4f 100644 --- a/packages/frosted-ui/package.json +++ b/packages/frosted-ui/package.json @@ -64,6 +64,7 @@ "@radix-ui/react-direction": "^1.0.1", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-portal": "^1.0.4", "@radix-ui/react-progress": "^1.0.3", diff --git a/packages/frosted-ui/src/components/base-tabs-list.css b/packages/frosted-ui/src/components/base-tabs-list.css new file mode 100644 index 00000000..ebb34bd8 --- /dev/null +++ b/packages/frosted-ui/src/components/base-tabs-list.css @@ -0,0 +1,144 @@ +.fui-BaseTabsList { + display: flex; + overflow-x: auto; + white-space: nowrap; + + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +.fui-BaseTabsTrigger { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + flex-shrink: 0; + position: relative; + user-select: none; +} + +.fui-BaseTabsTriggerInner, +.fui-BaseTabsTriggerInnerHidden { + display: flex; + align-items: center; + justify-content: center; +} + +.fui-BaseTabsTriggerInner { + position: absolute; + + :where(.fui-BaseTabsTrigger[data-state='inactive'], .fui-TabsNavLink:not([data-active])) & { + letter-spacing: var(--tabs-trigger-inactive-letter-spacing); + word-spacing: var(--tabs-trigger-inactive-word-spacing); + } + + :where(.fui-BaseTabsTrigger[data-state='active'], .fui-TabsNavLink[data-active]) & { + font-weight: var(--font-weight-medium); + letter-spacing: var(--tabs-trigger-active-letter-spacing); + word-spacing: var(--tabs-trigger-active-word-spacing); + } +} + +.fui-BaseTabsTriggerInnerHidden { + visibility: hidden; + font-weight: var(--font-weight-medium); + letter-spacing: var(--tabs-trigger-active-letter-spacing); + word-spacing: var(--tabs-trigger-active-word-spacing); +} + +.fui-BaseTabsContent { + position: relative; + outline: 0; +} + +/*************************************************************************************************** + * * + * SIZES * + * * + ***************************************************************************************************/ + +.fui-BaseTabsTrigger { + padding-left: var(--tabs-trigger-padding-x); + padding-right: var(--tabs-trigger-padding-x); +} + +.fui-BaseTabsTriggerInner, +.fui-BaseTabsTriggerInnerHidden { + padding: var(--tabs-trigger-inner-padding-y) + var(--tabs-trigger-inner-padding-x); + border-radius: var(--tabs-trigger-inner-border-radius); +} + +@breakpoints { + .fui-BaseTabsList { + &:where(.fui-r-size-1) { + height: 36px; + font-size: var(--font-size-1); + line-height: var(--line-height-1); + letter-spacing: var(--letter-spacing-1); + --tabs-trigger-padding-x: var(--space-1); + --tabs-trigger-inner-padding-x: calc(1.5 * var(--space-1)); + --tabs-trigger-inner-padding-y: var(--space-1); + --tabs-trigger-inner-border-radius: var(--radius-2); + } + &:where(.fui-r-size-2) { + height: var(--space-7); + font-size: var(--font-size-2); + line-height: var(--line-height-2); + letter-spacing: var(--letter-spacing-2); + --tabs-trigger-padding-x: var(--space-1); + --tabs-trigger-inner-padding-x: calc(1.25 * var(--space-2)); + --tabs-trigger-inner-padding-y: var(--space-1); + --tabs-trigger-inner-border-radius: var(--radius-3); + } + } +} + +/*************************************************************************************************** + * * + * VARIANTS * + * * + ***************************************************************************************************/ + +.fui-BaseTabsList { + box-shadow: inset 0 -1px 0 0 var(--gray-a5); +} + +.fui-BaseTabsTrigger { + color: var(--gray-a11); + + @media (hover: hover) { + &:where(:hover) { + color: var(--gray-12); + } + &:where(:hover) :where(.fui-BaseTabsTriggerInner) { + background-color: var(--gray-a3); + } + &:where(:focus-visible:hover) :where(.fui-BaseTabsTriggerInner) { + background-color: var(--accent-a3); + } + } + &:where([data-state='active'], [data-active]) { + color: var(--gray-12); + } + &:where(:focus-visible) :where(.fui-BaseTabsTriggerInner) { + outline: 2px solid var(--color-focus-root); + outline-offset: -2px; + } + &:where([data-state='active'], [data-active])::before { + box-sizing: border-box; + content: ''; + height: 2px; + position: absolute; + bottom: 0; + left: 0; + right: 0; + background-color: var(--accent-10); + } +} + +.fui-BaseTabsContent:where(:focus-visible) { + outline: 2px solid var(--color-focus-root); +} diff --git a/packages/frosted-ui/src/components/base-tabs-list.props.ts b/packages/frosted-ui/src/components/base-tabs-list.props.ts new file mode 100644 index 00000000..06098b42 --- /dev/null +++ b/packages/frosted-ui/src/components/base-tabs-list.props.ts @@ -0,0 +1,11 @@ +import { PropDef } from '../helpers'; + +const sizes = ['1', '2'] as const; + +const baseTabsListPropDefs = { + size: { type: 'enum', values: sizes, default: '2', responsive: true }, +} satisfies { + size: PropDef<(typeof sizes)[number]>; +}; + +export { baseTabsListPropDefs }; diff --git a/packages/frosted-ui/src/components/index.ts b/packages/frosted-ui/src/components/index.ts index 5844fbe5..7a2b278c 100644 --- a/packages/frosted-ui/src/components/index.ts +++ b/packages/frosted-ui/src/components/index.ts @@ -245,6 +245,8 @@ export { } from './table'; export * from './table.props'; export { Tabs, TabsContent, TabsList, TabsRoot, TabsTrigger } from './tabs'; +export { TabsNav, TabsNavLink, TabsNavRoot } from './tabs-nav'; +export * from './tabs-nav.props'; export * from './tabs.props'; // export * from './toast'; // export * from './toggle'; diff --git a/packages/frosted-ui/src/components/tabs-nav.css b/packages/frosted-ui/src/components/tabs-nav.css new file mode 100644 index 00000000..808ff16e --- /dev/null +++ b/packages/frosted-ui/src/components/tabs-nav.css @@ -0,0 +1,5 @@ +@import './base-tabs-list.css'; + +.fui-TabsNavItem { + display: flex; +} diff --git a/packages/frosted-ui/src/components/tabs-nav.props.ts b/packages/frosted-ui/src/components/tabs-nav.props.ts new file mode 100644 index 00000000..f5623bc8 --- /dev/null +++ b/packages/frosted-ui/src/components/tabs-nav.props.ts @@ -0,0 +1,10 @@ +import { asChildProp } from '../helpers'; + +const tabsNavLinkPropDefs = { + asChild: asChildProp, +} satisfies { + asChild: typeof asChildProp; +}; + +export { baseTabsListPropDefs as tabsNavPropDefs } from './base-tabs-list.props'; +export { tabsNavLinkPropDefs }; diff --git a/packages/frosted-ui/src/components/tabs-nav.tsx b/packages/frosted-ui/src/components/tabs-nav.tsx new file mode 100644 index 00000000..b2bfdac0 --- /dev/null +++ b/packages/frosted-ui/src/components/tabs-nav.tsx @@ -0,0 +1,121 @@ +'use client'; + +import * as NavigationMenu from '@radix-ui/react-navigation-menu'; +import classNames from 'classnames'; +import * as React from 'react'; +import { + GetPropDefTypes, + MarginProps, + extractMarginProps, + getSubtree, + withBreakpoints, + withMarginProps, +} from '../helpers'; +import { tabsNavLinkPropDefs, tabsNavPropDefs } from './tabs-nav.props'; + +type TabsNavRootElement = React.ElementRef; +type TabsNavOwnProps = GetPropDefTypes; +interface TabsNavRootProps + extends Omit< + React.ComponentPropsWithoutRef, + | 'asChild' + | 'orientation' + | 'defauValue' + | 'value' + | 'onValueChange' + | 'delayDuration' + | 'skipDelayDuration' + >, + MarginProps, + TabsNavOwnProps {} +const TabsNavRoot = React.forwardRef( + (props, forwardedRef) => { + const { rest: marginRest, ...marginProps } = extractMarginProps(props); + const { + children, + className, + size = tabsNavPropDefs.size.default, + ...rootProps + } = marginRest; + + return ( + + + {children} + + + ); + }, +); +TabsNavRoot.displayName = 'TabsNavRoot'; + +type TabsNavLinkElement = React.ElementRef; +type TabsNavLinkOwnProps = GetPropDefTypes; +interface TabsNavLinkProps + extends Omit< + React.ComponentPropsWithoutRef, + 'onSelect' + >, + TabsNavLinkOwnProps {} +const TabsNavLink = React.forwardRef( + (props, forwardedRef) => { + const { asChild, children, className, ...linkProps } = props; + + return ( + + {}} + asChild={asChild} + > + {getSubtree({ asChild, children }, (children) => ( + <> + + {children} + + + {children} + + + ))} + + + ); + }, +); + +TabsNavLink.displayName = 'TabsNavLink'; + +const TabsNav = Object.assign( + {}, + { + Root: TabsNavRoot, + Link: TabsNavLink, + }, +); + +export { TabsNav, TabsNavLink, TabsNavRoot }; +export type { TabsNavLinkProps, TabsNavRootProps }; diff --git a/packages/frosted-ui/src/components/tabs.css b/packages/frosted-ui/src/components/tabs.css index 175a8c66..28805040 100644 --- a/packages/frosted-ui/src/components/tabs.css +++ b/packages/frosted-ui/src/components/tabs.css @@ -1,144 +1,10 @@ -.fui-TabsList { - display: flex; - overflow-x: auto; - white-space: nowrap; - - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } -} - -.fui-TabsTrigger { - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - flex-shrink: 0; - position: relative; - user-select: none; -} - -.fui-TabsTriggerInner, -.fui-TabsTriggerInnerHidden { - display: flex; - align-items: center; - justify-content: center; -} - -.fui-TabsTriggerInner { - position: absolute; - - :where(.fui-TabsTrigger[data-state='inactive']) & { - letter-spacing: var(--tabs-trigger-inactive-letter-spacing); - word-spacing: var(--tabs-trigger-inactive-word-spacing); - } - - :where(.fui-TabsTrigger[data-state='active']) & { - font-weight: var(--font-weight-medium); - letter-spacing: var(--tabs-trigger-active-letter-spacing); - word-spacing: var(--tabs-trigger-active-word-spacing); - } -} - -.fui-TabsTriggerInnerHidden { - visibility: hidden; - font-weight: var(--font-weight-medium); - letter-spacing: var(--tabs-trigger-active-letter-spacing); - word-spacing: var(--tabs-trigger-active-word-spacing); -} +@import './base-tabs-list.css'; .fui-TabsContent { position: relative; outline: 0; } -/*************************************************************************************************** - * * - * SIZES * - * * - ***************************************************************************************************/ - -.fui-TabsTrigger { - padding-left: var(--tabs-trigger-padding-x); - padding-right: var(--tabs-trigger-padding-x); -} - -.fui-TabsTriggerInner, -.fui-TabsTriggerInnerHidden { - padding: var(--tabs-trigger-inner-padding-y) - var(--tabs-trigger-inner-padding-x); - border-radius: var(--tabs-trigger-inner-border-radius); -} - -@breakpoints { - .fui-TabsList { - &:where(.fui-r-size-1) { - height: 36px; - font-size: var(--font-size-1); - line-height: var(--line-height-1); - letter-spacing: var(--letter-spacing-1); - --tabs-trigger-padding-x: var(--space-1); - --tabs-trigger-inner-padding-x: calc(1.5 * var(--space-1)); - --tabs-trigger-inner-padding-y: var(--space-1); - --tabs-trigger-inner-border-radius: var(--radius-2); - } - &:where(.fui-r-size-2) { - height: var(--space-7); - font-size: var(--font-size-2); - line-height: var(--line-height-2); - letter-spacing: var(--letter-spacing-2); - --tabs-trigger-padding-x: var(--space-1); - --tabs-trigger-inner-padding-x: calc(1.25 * var(--space-2)); - --tabs-trigger-inner-padding-y: var(--space-1); - --tabs-trigger-inner-border-radius: var(--radius-3); - } - } -} - -/*************************************************************************************************** - * * - * VARIANTS * - * * - ***************************************************************************************************/ - -.fui-TabsList { - box-shadow: inset 0 -1px 0 0 var(--gray-a5); -} - -.fui-TabsTrigger { - color: var(--gray-a11); - - @media (hover: hover) { - &:where(:hover) { - color: var(--gray-12); - } - &:where(:hover) :where(.fui-TabsTriggerInner) { - background-color: var(--gray-a3); - } - &:where(:focus-visible:hover) :where(.fui-TabsTriggerInner) { - background-color: var(--accent-a3); - } - } - &:where([data-state='active']) { - color: var(--gray-12); - } - &:where(:focus-visible) :where(.fui-TabsTriggerInner) { - outline: 2px solid var(--color-focus-root); - outline-offset: -2px; - } - &:where([data-state='active'])::before { - box-sizing: border-box; - content: ''; - height: 2px; - position: absolute; - bottom: 0; - left: 0; - right: 0; - background-color: var(--accent-10); - } -} - .fui-TabsContent:where(:focus-visible) { outline: 2px solid var(--color-focus-root); } diff --git a/packages/frosted-ui/src/components/tabs.props.ts b/packages/frosted-ui/src/components/tabs.props.ts index 2209e79c..58196af1 100644 --- a/packages/frosted-ui/src/components/tabs.props.ts +++ b/packages/frosted-ui/src/components/tabs.props.ts @@ -1,11 +1 @@ -import { PropDef } from '../helpers'; - -const sizes = ['1', '2'] as const; - -const tabsListPropDefs = { - size: { type: 'enum', values: sizes, default: '2', responsive: true }, -} satisfies { - size: PropDef<(typeof sizes)[number]>; -}; - -export { tabsListPropDefs }; +export { baseTabsListPropDefs as tabsListPropDefs } from './base-tabs-list.props'; diff --git a/packages/frosted-ui/src/components/tabs.tsx b/packages/frosted-ui/src/components/tabs.tsx index 150fefbe..823c578b 100644 --- a/packages/frosted-ui/src/components/tabs.tsx +++ b/packages/frosted-ui/src/components/tabs.tsx @@ -52,6 +52,7 @@ const TabsList = React.forwardRef( {...listProps} ref={forwardedRef} className={classNames( + 'fui-BaseTabsList', 'fui-TabsList', className, withBreakpoints(size, 'fui-r-size'), @@ -72,10 +73,19 @@ const TabsTrigger = React.forwardRef( - {children} - {children} + + {children} + + + {children} + ); }, diff --git a/packages/frosted-ui/src/helpers/get-subtree.ts b/packages/frosted-ui/src/helpers/get-subtree.ts new file mode 100644 index 00000000..51595eba --- /dev/null +++ b/packages/frosted-ui/src/helpers/get-subtree.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; + +/** + * This is a helper function that is used when a component supports `asChild` + * using the `Slot` component but its implementation contains nested DOM elements. + * + * Using it ensures if a consumer uses the `asChild` prop, the elements are in + * correct order in the DOM, adopting the intended consumer `children`. + */ +export function getSubtree( + options: { asChild: boolean | undefined; children: React.ReactNode }, + content: React.ReactNode | ((children: React.ReactNode) => React.ReactNode) +) { + const { asChild, children } = options; + if (!asChild) return typeof content === 'function' ? content(children) : content; + + const firstChild = React.Children.only(children) as React.ReactElement; + return React.cloneElement(firstChild, { + children: typeof content === 'function' ? content(firstChild.props.children) : content, + }); +} diff --git a/packages/frosted-ui/src/helpers/index.ts b/packages/frosted-ui/src/helpers/index.ts index c49113e8..92c4eab3 100644 --- a/packages/frosted-ui/src/helpers/index.ts +++ b/packages/frosted-ui/src/helpers/index.ts @@ -1,6 +1,7 @@ -export * from './props'; export * from './breakpoints'; export * from './extract-props-for-tag'; +export * from './get-subtree'; export * from './has-own-property'; export * from './nice-intersection'; +export * from './props'; export * from './radix-colors'; diff --git a/packages/frosted-ui/src/helpers/props/as-child.prop.ts b/packages/frosted-ui/src/helpers/props/as-child.prop.ts new file mode 100644 index 00000000..6b04bd52 --- /dev/null +++ b/packages/frosted-ui/src/helpers/props/as-child.prop.ts @@ -0,0 +1,8 @@ +import { PropDef } from './prop-def.js'; + +const asChildProp = { + type: 'boolean', + default: undefined, +} satisfies PropDef; + +export { asChildProp }; diff --git a/packages/frosted-ui/src/helpers/props/index.ts b/packages/frosted-ui/src/helpers/props/index.ts index c418154c..7c9c7242 100644 --- a/packages/frosted-ui/src/helpers/props/index.ts +++ b/packages/frosted-ui/src/helpers/props/index.ts @@ -1,3 +1,4 @@ +export * from './as-child.prop'; export * from './color.prop'; export * from './high-contrast.prop'; export * from './layout.props'; diff --git a/packages/frosted-ui/src/styles/index.css b/packages/frosted-ui/src/styles/index.css index 48039773..509464b5 100644 --- a/packages/frosted-ui/src/styles/index.css +++ b/packages/frosted-ui/src/styles/index.css @@ -54,6 +54,7 @@ @import '../components/strong.css'; @import '../components/switch.css'; @import '../components/tabs.css'; +@import '../components/tabs-nav.css'; @import '../components/table.css'; @import '../components/text-area.css'; @import '../components/text-field.css'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fb5681a..23d56ade 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: '@radix-ui/react-hover-card': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.7)(@types/react@18.0.22)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-navigation-menu': + specifier: ^1.1.4 + version: 1.1.4(@types/react-dom@18.2.7)(@types/react@18.0.22)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.7)(@types/react@18.0.22)(react-dom@18.2.0)(react@18.2.0) @@ -3090,6 +3093,40 @@ packages: react-remove-scroll: 2.5.5(@types/react@18.0.22)(react@18.2.0) dev: false + /@radix-ui/react-navigation-menu@1.1.4(@types/react-dom@18.2.7)(@types/react@18.0.22)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Cc+seCS3PmWmjI51ufGG7zp1cAAIRqHVw7C9LOA2TZ+R4hG6rDvHcTqIsEEFLmZO3zNVH72jOOE7kKNy8W+RtA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.6 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.0.22)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.0.22)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.0.22)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.0.22)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.7)(@types/react@18.0.22)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.0.22)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.0.22)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.0.22)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.0.22)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.0.22)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.0.22)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.0.22)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.0.22)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.0.22 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-popover@1.0.7(@types/react-dom@18.2.7)(@types/react@18.0.22)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==} peerDependencies: