From 7a35dd40e044e1bb1ed250b2c1683ba7ed8ee113 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 15:53:50 +0000 Subject: [PATCH 1/2] Initial plan From e0e24d0d33d8f55342e738ef4b98eeed2795a6a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:05:10 +0000 Subject: [PATCH 2/2] Complete Toolbar implementation with all Material Design 3 specifications Co-authored-by: YieldRay <24633623+YieldRay@users.noreply.github.com> --- src/components/index.ts | 1 + src/components/toolbar/Toolbar.stories.tsx | 320 +++++++++++++++++++++ src/components/toolbar/Toolbar.tsx | 105 +++++++ src/components/toolbar/index.ts | 1 + src/components/toolbar/toolbar.scss | 253 ++++++++++++++++ 5 files changed, 680 insertions(+) create mode 100644 src/components/toolbar/Toolbar.stories.tsx create mode 100644 src/components/toolbar/Toolbar.tsx create mode 100644 src/components/toolbar/index.ts create mode 100644 src/components/toolbar/toolbar.scss diff --git a/src/components/index.ts b/src/components/index.ts index d7dd4e3..9b31a06 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -24,4 +24,5 @@ export * from './switch' export * from './tabs' export * from './text-field' export * from './time-picker' +export * from './toolbar' export * from './tooltip' diff --git a/src/components/toolbar/Toolbar.stories.tsx b/src/components/toolbar/Toolbar.stories.tsx new file mode 100644 index 0000000..faf131a --- /dev/null +++ b/src/components/toolbar/Toolbar.stories.tsx @@ -0,0 +1,320 @@ +import { useState } from 'react' +import { + mdiContentCopy, + mdiContentCut, + mdiContentPaste, + mdiFormatBold, + mdiFormatItalic, + mdiFormatUnderline, + mdiMagnify, + mdiPlus, + mdiShare, + mdiUndo, + mdiRedo +} from '@mdi/js' +import Icon from '@mdi/react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Portal } from '@/utils/Portal' +import { Button } from '../button' +import { IconButton } from '../icon-button' +import { TextField } from '../text-field' +import { Fab } from '../fab' +import { Toolbar } from './Toolbar' + +export default { + title: 'Components/Toolbar', + component: Toolbar, + parameters: { + layout: 'fullscreen', + }, + argTypes: { + variant: { + control: { type: 'radio' }, + options: ['docked', 'floating'], + }, + colorScheme: { + control: { type: 'radio' }, + options: ['standard', 'vibrant'], + }, + orientation: { + control: { type: 'radio' }, + options: ['horizontal', 'vertical'], + }, + fixed: { + control: { type: 'boolean' }, + }, + }, +} satisfies Meta + +type Story = StoryObj + +export const DockedStandard: Story = { + render: (args) => { + const [fixed, setFixed] = useState(false) + + return ( +
+ + +

+ Docked toolbars are similar to bottom app bars but more flexible. + They display frequently used actions and can contain various control types. +

+ +

Try the toolbar below with different actions:

+ + + + + + + + + + + } + fab={} + /> + +
+ ) + }, +} + +export const DockedVibrant: Story = { + render: (args) => ( +
+

+ Vibrant color scheme uses primary container colors for greater emphasis. +

+ + + + + + + + + + } + /> + +
+ ), +} + +export const FloatingHorizontal: Story = { + render: (args) => ( +
+

Floating toolbars can be positioned anywhere and have rounded corners.

+ + + + + + + } + /> +
+ ), +} + +export const FloatingVertical: Story = { + render: (args) => ( +
+

Vertical floating toolbars stack actions vertically.

+ + + + + + + + } + /> +
+ ), +} + +export const FloatingWithFAB: Story = { + render: (args) => ( +
+

Floating toolbars can be paired with FABs to emphasize certain actions.

+ + + + + + } + fab={} + /> +
+ ), +} + +export const InteractiveExample: Story = { + render: () => { + const [variant, setVariant] = useState<'docked' | 'floating'>('docked') + const [colorScheme, setColorScheme] = useState<'standard' | 'vibrant'>('standard') + const [orientation, setOrientation] = useState<'horizontal' | 'vertical'>('horizontal') + const [showFab, setShowFab] = useState(true) + + return ( +
+
+
+ + +
+ +
+ + +
+ + {variant === 'floating' && ( +
+ + +
+ )} + +
+ +
+
+ +
+ {variant === 'docked' ? ( + + + + + + {variant === 'docked' && ( + + )} + + } + fab={showFab ? : undefined} + /> + + ) : ( + + + + + + + } + fab={showFab ? : undefined} + /> + )} +
+
+ ) + }, +} \ No newline at end of file diff --git a/src/components/toolbar/Toolbar.tsx b/src/components/toolbar/Toolbar.tsx new file mode 100644 index 0000000..18925e8 --- /dev/null +++ b/src/components/toolbar/Toolbar.tsx @@ -0,0 +1,105 @@ +import { mergeStyles } from '@/utils/style' +import './toolbar.scss' +import clsx from 'clsx' +import { forwardRef } from 'react' +import { ExtendProps } from '@/utils/type' + +/** + * `` displays frequently used actions relevant to the current page + * + * Two expressive types: docked toolbar and floating toolbar + * Use the vibrant color style for greater emphasis + * Can display a wide variety of control types, like buttons, icon buttons, and text fields + * Can be paired with FABs to emphasize certain actions + * Don't show at the same time as a navigation bar + * + * @specs https://m3.material.io/components/toolbars + */ +export const Toolbar = forwardRef< + HTMLDivElement, + ExtendProps<{ + /** + * Toolbar variant + * @default "docked" + */ + variant?: 'docked' | 'floating' + /** + * Color scheme for the toolbar + * @default "standard" + */ + colorScheme?: 'standard' | 'vibrant' + /** + * Orientation for floating toolbars + * @default "horizontal" + */ + orientation?: 'horizontal' | 'vertical' + /** + * Actions to display in the toolbar (buttons, icon buttons, text fields, etc.) + */ + actions?: React.ReactNode + /** + * FAB component for floating toolbar configurations + */ + fab?: React.ReactNode + /** + * Fix the toolbar to a specific position + * For docked: typically bottom or top + * For floating: can be positioned anywhere + */ + fixed?: boolean + /** + * CSS `z-index`, if `fixed` set to `true` + * @default 1 + */ + zIndex?: number + /** + * CSS `inset`, if `fixed` set to `true` + * For docked bottom: 'auto 0 0' + * For docked top: '0 0 auto' + */ + inset?: string + }> +>(function Toolbar( + { + variant = 'docked', + colorScheme = 'standard', + orientation = 'horizontal', + actions, + fab, + fixed, + zIndex = 1, + inset, + className, + style, + ...props + }, + ref, +) { + return ( +
+
+
{actions}
+ {fab &&
{fab}
} +
+
+ ) +}) \ No newline at end of file diff --git a/src/components/toolbar/index.ts b/src/components/toolbar/index.ts new file mode 100644 index 0000000..2915ee2 --- /dev/null +++ b/src/components/toolbar/index.ts @@ -0,0 +1 @@ +export * from './Toolbar' \ No newline at end of file diff --git a/src/components/toolbar/toolbar.scss b/src/components/toolbar/toolbar.scss new file mode 100644 index 0000000..57e3820 --- /dev/null +++ b/src/components/toolbar/toolbar.scss @@ -0,0 +1,253 @@ +// https://m3.material.io/components/toolbars + +.sd-toolbar { + box-sizing: border-box; + width: 100%; + height: 64px; + padding: 16px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + &-content { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + gap: 8px; + } + + &-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + &-fab { + user-select: none; + margin-left: 8px; + } + + // Docked Toolbar + &--docked { + width: 100%; + + &.sd-toolbar--standard { + background: var(--md-sys-color-surface-container); + @include elevation-level2; + } + + &.sd-toolbar--vibrant { + background: var(--md-sys-color-primary-container); + @include elevation-level2; + } + + .sd-toolbar-content { + justify-content: space-between; + padding: 0 16px; + min-height: 32px; + } + + .sd-toolbar-actions { + flex-grow: 1; + justify-content: flex-start; + } + } + + // Floating Toolbar + &--floating { + width: auto; + border-radius: var(--md-sys-shape-corner-full); + min-width: 64px; + + &.sd-toolbar--standard { + background: var(--md-sys-color-surface-container); + @include elevation-level3; + } + + &.sd-toolbar--vibrant { + background: var(--md-sys-color-primary-container); + @include elevation-level3; + } + + .sd-toolbar-content { + padding: 0 8px; + min-height: 32px; + } + + .sd-toolbar-actions { + justify-content: center; + } + + // Vertical floating toolbar + &.sd-toolbar--vertical { + width: 64px; + height: auto; + min-height: 64px; + + .sd-toolbar-content { + flex-direction: column; + padding: 8px 0; + } + + .sd-toolbar-actions { + flex-direction: column; + } + + .sd-toolbar-fab { + margin-left: 0; + margin-top: 8px; + } + } + } + + // Toolbar button containers and states + // These styles work with the existing button and icon-button components + .sd-button, + .sd-icon_button { + // Standard color scheme states + .sd-toolbar--standard & { + // Default state + background: var(--md-sys-color-surface-container); + color: var(--md-sys-color-on-surface-variant); + + // Selected state + &[data-selected="true"], + &.selected { + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + } + + // Disabled state + &:disabled { + color: var(--md-sys-color-on-surface); + opacity: 0.38; + } + + // Hover state + &:hover:not(:disabled) { + &::before { + background: var(--md-sys-color-on-surface-variant); + opacity: var(--md-sys-state-hover-state-layer-opacity); + } + + &[data-selected="true"]::before, + &.selected::before { + background: var(--md-sys-color-on-secondary-container); + } + } + + // Focus state + &:focus-visible:not(:disabled) { + &::before { + background: var(--md-sys-color-on-surface-variant); + opacity: var(--md-sys-state-focus-state-layer-opacity); + } + + &[data-selected="true"]::before, + &.selected::before { + background: var(--md-sys-color-on-secondary-container); + } + } + + // Pressed state + &:active:not(:disabled) { + &::before { + background: var(--md-sys-color-on-surface-variant); + opacity: var(--md-sys-state-pressed-state-layer-opacity); + } + + &[data-selected="true"]::before, + &.selected::before { + background: var(--md-sys-color-on-secondary-container); + } + } + } + + // Vibrant color scheme states + .sd-toolbar--vibrant & { + // Default state + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + + // Selected state + &[data-selected="true"], + &.selected { + background: var(--md-sys-color-surface-container); + color: var(--md-sys-color-on-surface); + } + + // Disabled state + &:disabled { + color: var(--md-sys-color-on-surface); + opacity: 0.38; + } + + // Hover state + &:hover:not(:disabled) { + &::before { + background: var(--md-sys-color-on-primary-container); + opacity: var(--md-sys-state-hover-state-layer-opacity); + } + + &[data-selected="true"]::before, + &.selected::before { + background: var(--md-sys-color-on-surface); + } + } + + // Focus state + &:focus-visible:not(:disabled) { + &::before { + background: var(--md-sys-color-on-primary-container); + opacity: var(--md-sys-state-focus-state-layer-opacity); + } + + &[data-selected="true"]::before, + &.selected::before { + background: var(--md-sys-color-on-surface); + } + } + + // Pressed state + &:active:not(:disabled) { + &::before { + background: var(--md-sys-color-on-primary-container); + opacity: var(--md-sys-state-pressed-state-layer-opacity); + } + + &[data-selected="true"]::before, + &.selected::before { + background: var(--md-sys-color-on-surface); + } + } + } + } + + // Responsive design for smaller screens + @media (max-width: 600px) { + &--docked { + .sd-toolbar-actions { + overflow-x: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + } + + &--floating { + .sd-toolbar-actions { + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + } + } +} \ No newline at end of file