diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 53b623558..954add1b3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -26,6 +26,7 @@ packages/react-contextual-pane @microsoft/teams-prg packages/react-virtualizer @microsoft/xc-uxe @microsoft/cxe-prg packages/react-cap-theme @microsoft/cap-theme packages/cap-foundations @microsoft/cap-theme +packages/react-app-launcher @blendsdk # <%= NX-CODEOWNER-PLACEHOLDER %> #### Build/Infra diff --git a/packages/react-app-launcher/.babelrc b/packages/react-app-launcher/.babelrc new file mode 100644 index 000000000..ac08da0a4 --- /dev/null +++ b/packages/react-app-launcher/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../../babel.config.json" +} diff --git a/packages/react-app-launcher/.storybook/main.ts b/packages/react-app-launcher/.storybook/main.ts new file mode 100644 index 000000000..025fde797 --- /dev/null +++ b/packages/react-app-launcher/.storybook/main.ts @@ -0,0 +1,11 @@ +import type { StorybookConfig } from '@storybook/react-webpack5'; + +// eslint-disable-next-line @nx/enforce-module-boundaries +import rootConfig from '../../../.storybook/main'; + +const config: StorybookConfig = { + ...rootConfig, + stories: ['../stories/**/index.stories.@(js|jsx|ts|tsx|mdx)'], +}; + +export default config; diff --git a/packages/react-app-launcher/.storybook/preview.tsx b/packages/react-app-launcher/.storybook/preview.tsx new file mode 100644 index 000000000..6716ed76f --- /dev/null +++ b/packages/react-app-launcher/.storybook/preview.tsx @@ -0,0 +1,11 @@ +import type { Preview } from '@storybook/react'; + +// eslint-disable-next-line @nx/enforce-module-boundaries +import rootPreview from '../../../.storybook/preview'; + +const preview: Preview = { + ...rootPreview, + tags: ['autodocs'], +}; + +export default preview; diff --git a/packages/react-app-launcher/.storybook/tsconfig.json b/packages/react-app-launcher/.storybook/tsconfig.json new file mode 100644 index 000000000..6fa15c2f4 --- /dev/null +++ b/packages/react-app-launcher/.storybook/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "emitDecoratorMetadata": true, + "outDir": "" + }, + "files": [ + "../../../node_modules/@nx/react/typings/styled-jsx.d.ts", + "../../../node_modules/@nx/react/typings/cssmodule.d.ts", + "../../../node_modules/@nx/react/typings/image.d.ts" + ], + "exclude": [ + "../**/*.spec.ts", + "../**/*.spec.js", + "../**/*.spec.tsx", + "../**/*.spec.jsx" + ], + "include": [ + "../stories/**/*.stories.ts", + "../stories/**/*.stories.js", + "../stories/**/*.stories.jsx", + "../stories/**/*.stories.tsx", + "../stories/**/*.stories.mdx", + "*.ts", + "*.js" + ] +} diff --git a/packages/react-app-launcher/.swcrc b/packages/react-app-launcher/.swcrc new file mode 100644 index 000000000..0f4746f47 --- /dev/null +++ b/packages/react-app-launcher/.swcrc @@ -0,0 +1,30 @@ +{ + "jsc": { + "target": "es2019", + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": false, + "dynamicImport": false + }, + "transform": { + "react": { + "runtime": "classic", + "useSpread": true + } + }, + "keepClassNames": true, + "externalHelpers": true, + "loose": true + }, + "sourceMaps": true, + "exclude": [ + "jest.config.ts", + ".*\\.spec.tsx?$", + ".*\\.test.tsx?$", + "./src/jest-setup.ts$", + "./**/jest-setup.ts$", + ".*.js$" + ], + "$schema": "https://json.schemastore.org/swcrc" +} diff --git a/packages/react-app-launcher/README.md b/packages/react-app-launcher/README.md new file mode 100644 index 000000000..66db7d80f --- /dev/null +++ b/packages/react-app-launcher/README.md @@ -0,0 +1,226 @@ +# @fluentui-contrib/react-app-launcher + +A Microsoft 365-style app launcher for Fluent UI v9: a waffle trigger that opens +a docked panel of application tiles and rows. + +The package ships no data and no application state. Which apps exist, when one +is disabled, what a search matches and what "recent" means are decisions of the +calling application — the package renders what it is given. The Storybook +stories are the live reference (see "Development" below). + +## Install + +```bash +npm install @fluentui-contrib/react-app-launcher +``` + +`react` and `@fluentui/react-components` are peer dependencies. + +All blocks are exported from the package root: + +```tsx +import { AppLauncher, AppLauncherGrid, AppLauncherItem, AppLauncherList, AppLauncherSearch, AppLauncherSection, AppLauncherTrigger } from '@fluentui-contrib/react-app-launcher'; +``` + +## Quick start + +A launcher in an application bar: the waffle trigger, a search field, a pinned +grid, and a result list while searching. The panel docks to the bar so it opens +flush with the window edge, the way the Microsoft 365 launcher behaves. + +```tsx +import { AppLauncher, AppLauncherGrid, AppLauncherItem, AppLauncherList, AppLauncherSearch, AppLauncherSection } from '@fluentui-contrib/react-app-launcher'; +import { useRef, useState } from 'react'; + +function AppMenu() { + const barRef = useRef(null); + const [query, setQuery] = useState(''); + + const needle = query.trim().toLowerCase(); + const matches = apps.filter((app) => app.label.toLowerCase().includes(needle)); + + return ( +
+ + setQuery(data.value)} /> + {needle === '' ? ( + + + {apps.map((app) => ( + open(app)} /> + ))} + + + ) : ( + + + {matches.map((app) => ( + open(app)} /> + ))} + + + )} + +
+ ); +} +``` + +## Blocks + +| Block | Purpose | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AppLauncher` | The shell: waffle trigger plus the docked popover panel. | +| `AppLauncherSearch` | The search field, with the launcher's Escape behavior. | +| `AppLauncherSection` | Groups content under an optional title, with spacing and an optional divider. | +| `AppLauncherGrid` | Lays items out as tiles; three columns by default. | +| `AppLauncherList` | Lays items out as full-width rows. | +| `AppLauncherItem` | One app — a tile or a row, depending on the surrounding container. | +| `AppLauncherTrigger` | The built-in waffle trigger button; the launcher renders it unless a caller-provided `trigger` replaces it. Exported for applications that place it themselves. | + +### AppLauncher + +| Prop | Description | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `label` | Accessible name of the trigger and its tooltip. Defaults to `'App launcher'` — pass a localized string. Applies to the built-in trigger only. | +| `icon` | Trigger icon override. Defaults to the waffle glyph. Applies to the built-in trigger only. | +| `trigger` | Caller-provided trigger element, used instead of the built-in waffle button — for example a Fluent `ToolbarButton`, so the launcher can live in an application toolbar or a drawer header. The element must accept and forward injected props and a ref (the `PopoverTrigger` contract) and owns its accessible name and tooltip. | +| `positioning` | Fluent popover positioning. Defaults to below the anchor, aligned with its start edge. | +| `dockTarget` | Element to anchor the panel to instead of the trigger — usually the application bar, so the panel docks flush with the window edge. The element must stay mounted while the launcher is open. | +| `docking` | `'default'` wraps the content; `'full'` stretches the panel from its anchored top edge to the bottom of the window. Either way the panel is capped at the window bottom and scrolls internally when the content is taller. | +| `open` | Controlled open state. Leave it undefined for the uncontrolled default; pass it with `onOpenChange` to close the panel from an item click, for example. | +| `onOpenChange` | Fired whenever the launcher asks to change the open state: the trigger, Escape, or a click outside. | +| `children` | The panel content: search, sections, grids and lists. | + +### AppLauncherSearch + +A Fluent `SearchBox` with the launcher defaults. `label` is required and +becomes the accessible name (no visible label is rendered). `onChange` fires +with `{ value }` while typing, from the clear button, and when Escape clears a +controlled field. The remaining `SearchBox` props (`value`, `placeholder`, +`size`, ...) pass through; `onKeyDown` is owned by the block because the Escape +behavior lives there. + +### AppLauncherSection + +`title` renders above the content; `divider` draws a line between the section +and the previous one. Sections that follow another section get spacing either +way, so a plain `` (no title) works as an unlabeled group. + +### AppLauncherItem + +| Prop | Description | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `label` | The app name; also the accessible name. | +| `icon` | Any element: a sized Fluent icon (`*32` for tiles, `*20` for rows) or a colorful logo element. The item does not constrain the size. | +| `disabled` | Renders the item disabled — no activation, muted name, dimmed icon — for items an application disables from business rules. | +| `disabledFocusable` | Renders the item disabled but reachable: announced as disabled, kept in the tab order, never activating — click, Enter and Space do not fire `onClick`, and a focused item keeps the normal focus ring; the same muted look as `disabled`. When both `disabled` and `disabledFocusable` are set, the Button's precedence applies — the item stays focusable and announced disabled. | +| … | All native button props (`onClick`, `className`, `aria-*`, ...) pass through. | + +The container decides the layout: inside `AppLauncherGrid` the item renders as a +tile (icon above the name), inside `AppLauncherList` as a row (icon beside the +name, long names end in an ellipsis). The same `AppLauncherItem` is used for +both. + +## Composition patterns + +- **The application owns the state.** The package holds no query and no recent + list: keep them in the calling component and switch what the panel renders, + as the quick start does. The kitchen-sink story contains a fuller version + with a recent section that updates when an app is clicked. +- **Activating an app closes the panel.** Keep the open state in the + application (`open` + `onOpenChange`) and close it from the item click, the + way the Microsoft 365 launcher behaves. +- **Tiles for browsing, rows for scanning.** A common layout is grids of tiles + for pinned and all apps, and lists of rows for search results and recents — + mixed freely inside one panel, section by section. +- **Icons are free-form.** Monochrome Fluent icons follow the item's text + color, including the disabled color. Colorful logos are dimmed automatically + while disabled (desaturated and faded to the lightness of the muted name). +- **The trigger can live in a toolbar.** Pass `trigger` with an element that + accepts injected props and a ref — for example a Fluent `ToolbarButton` — and + the launcher replaces its waffle with that element. The panel still anchors + to the element itself, so a waffle inside a drawer's toolbar or an + application bar opens the panel below the button. +- **Disabled items come from business rules.** Pass `disabled` per item; a + disabled item shows the disabled look, ignores clicks and is skipped by + keyboard navigation. Pass `disabledFocusable` instead to keep a disabled item + reachable for keyboard users: it shows the same muted look and never + activates, but stays in the tab order. When both are set, the Button's + precedence applies — the item stays focusable and announced disabled. +- **Empty search results need a state.** Render a short message (for example + "No apps found") in place of the result list, as the quick start shows. + +### Docking + +```tsx +// Default: the panel wraps its content, docked below the anchor. +… + +// Full: the panel reaches the bottom of the window and scrolls internally. +… + +// Anchored to the trigger instead of the bar, aligned with its end edge. +… +``` + +The full panel keeps the default docking's 8px rounding on its top corners and +stays square at the bottom, where it meets the window edge. + +## Behavior notes + +- The built-in trigger is an icon-only button. Its label is both the + accessible name and the tooltip; the tooltip is hidden while the panel is + open without remounting the button. With a caller-provided `trigger`, that + element is rendered as-is and owns its naming and tooltip. +- Keyboard: Tab moves through the panel content (no arrow-key grid), Enter and + Space activate an item, and Escape clears a controlled query first — the + second Escape closes the panel. +- A panel is capped at the space to the window bottom and scrolls internally + when its content is taller, so a long app list never grows the page. +- The search field stays at the top of the panel while the content scrolls + under it; the field never scrolls away. It forces the panel background — a + custom `appearance`'s base background is overridden while its hover and focus + states still apply. +- Tiles are white cards that stay flat at rest and lift with a soft shadow on + hover; the hovered tile paints above its neighbors so the shadow falls + evenly. The keyboard focus ring steps in front of the hover shadow. +- All colors, radii and shadows come from Fluent tokens, so the launcher + follows the application's `FluentProvider` theme. + +## Texts and localization + +Every user-facing string is a prop; the package ships no string resource files. +The defaults are English and can be overridden per application: + +| Text | Kind | Default | How to localize | +| -------------------------------- | ------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Trigger label (also the tooltip) | accessible name + tooltip | `'App launcher'` | `label` on `AppLauncher`/`AppLauncherTrigger` | +| Search field's accessible name | accessible | none — required | `label` on `AppLauncherSearch` | +| Search placeholder | visible | `'Search'` | `placeholder` (SearchBox passthrough) | +| Clear button on the search field | accessible | `'clear'` — set by Fluent's SearchBox | `dismiss={{ 'aria-label': '…' }}` (SearchBox passthrough; the slot merges over Fluent's default props) | +| Item names | visible + accessible | none — caller content | `label` on `AppLauncherItem` | +| Section titles | visible | none — caller content | `title` on `AppLauncherSection` (or omit) | +| Empty search state | visible | none — caller content | render your own message instead of the result list | + +A German search field — the override the localization story renders: + +```tsx + +``` + +## Development + +Run the repository's Nx targets from the repository root: + +```bash +yarn nx run react-app-launcher:build +yarn nx run react-app-launcher:test +yarn nx run react-app-launcher:lint +yarn nx run react-app-launcher:type-check +yarn nx run react-app-launcher:storybook +``` + +Tests live beside the sources (`src/**/*.test.tsx`). The stories under +`stories/AppLauncher` render every composition and the disabled, icon and +docking variants. diff --git a/packages/react-app-launcher/eslint.config.js b/packages/react-app-launcher/eslint.config.js new file mode 100644 index 000000000..2be3ed1ba --- /dev/null +++ b/packages/react-app-launcher/eslint.config.js @@ -0,0 +1,15 @@ +const baseConfig = require('../../eslint.config.js'); + +module.exports = [ + ...baseConfig, + { + files: ['**/*.ts', '**/*.tsx'], + // Override or add rules here + rules: {}, + }, + { + files: ['**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/packages/react-app-launcher/jest.config.cts b/packages/react-app-launcher/jest.config.cts new file mode 100644 index 000000000..5babebc52 --- /dev/null +++ b/packages/react-app-launcher/jest.config.cts @@ -0,0 +1,30 @@ +/* eslint-disable */ +const { readFileSync } = require('fs'); + +// Reading the SWC compilation config and remove the "exclude" +// for the test files to be compiled by SWC +const { exclude: _, ...swcJestConfig } = JSON.parse( + readFileSync(`${__dirname}/.swcrc`, 'utf-8') +); + +// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves. +// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude" +if (swcJestConfig.swcrc === undefined) { + swcJestConfig.swcrc = false; +} + +// Uncomment if using global setup/teardown files being transformed via swc +// https://nx.dev/nx-api/jest/documents/overview#global-setupteardown-with-nx-libraries +// jest needs EsModule Interop to find the default exported setup/teardown functions +// swcJestConfig.module.noInterop = false; + +module.exports = { + displayName: 'react-app-launcher', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]sx?$': ['@swc/jest', swcJestConfig], + }, + moduleFileExtensions: ['js', 'ts', 'tsx', 'html'], + testEnvironment: 'jsdom', + coverageDirectory: '../../coverage/packages/react-app-launcher', +}; diff --git a/packages/react-app-launcher/package.json b/packages/react-app-launcher/package.json new file mode 100644 index 000000000..c60766c87 --- /dev/null +++ b/packages/react-app-launcher/package.json @@ -0,0 +1,17 @@ +{ + "name": "@fluentui-contrib/react-app-launcher", + "version": "0.0.1", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.0.29", + "@swc/helpers": "~0.5.11" + }, + "peerDependencies": { + "@fluentui/react-components": ">=9.70.0 <10.0.0", + "@fluentui/react-icons": ">=2.0.204 <3.0.0", + "@types/react": ">=16.8.0 <20.0.0", + "react": ">=16.8.0 <20.0.0" + }, + "private": true +} diff --git a/packages/react-app-launcher/project.json b/packages/react-app-launcher/project.json new file mode 100644 index 000000000..b5f10e7cb --- /dev/null +++ b/packages/react-app-launcher/project.json @@ -0,0 +1,57 @@ +{ + "name": "react-app-launcher", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/react-app-launcher/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@fluentui-contrib/nx-plugin:build" + }, + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "lintFilePatterns": [ + "packages/react-app-launcher/**/*.ts", + "packages/react-app-launcher/**/*.tsx" + ] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/react-app-launcher/jest.config.cts", + "passWithNoTests": true + } + }, + "type-check": { + "executor": "@fluentui-contrib/nx-plugin:type-check" + }, + "storybook": { + "executor": "@nx/storybook:storybook", + "options": { + "port": 4400, + "configDir": "packages/react-app-launcher/.storybook" + }, + "configurations": { + "ci": { + "quiet": true + } + } + }, + "build-storybook": { + "executor": "@nx/storybook:build", + "outputs": ["{options.outputDir}"], + "options": { + "outputDir": "dist/storybook/react-app-launcher", + "configDir": "packages/react-app-launcher/.storybook" + }, + "configurations": { + "ci": { + "quiet": true + } + } + } + } +} diff --git a/packages/react-app-launcher/src/components/AppLauncher/AppLauncher.test.tsx b/packages/react-app-launcher/src/components/AppLauncher/AppLauncher.test.tsx new file mode 100644 index 000000000..8aca4a5d9 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncher/AppLauncher.test.tsx @@ -0,0 +1,205 @@ +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useRef } from 'react'; +import { AppLauncher, type AppLauncherProps } from '../../index'; + +/** + * Renders the launcher with a recognizable placeholder inside its surface, + * mirroring how applications mount it. + */ +function renderLauncher(props: Partial = {}) { + return render( + + +
Panel content
+
+
+ ); +} + +/** + * Mimics an application shell: a top bar with the launcher docked to it, the + * layout the component is built for. + */ +function DockedLauncherShell() { + const barRef = useRef(null); + + return ( + +
+ +
Panel content
+
+
+
+ ); +} + +/** + * Renders the launcher with a focusable element inside the surface, so focus + * movement can be exercised. + */ +function renderLauncherWithFocusablePanel() { + return render( + + + + + + ); +} + +describe('AppLauncher', () => { + it('keeps the surface closed until the trigger is activated', () => { + renderLauncher(); + + expect(screen.queryByText('Panel content')).toBeNull(); + }); + + it('opens the surface with its content when the trigger is activated', () => { + renderLauncher(); + + fireEvent.click(screen.getByRole('button', { name: 'App launcher' })); + + expect(screen.getByText('Panel content')).toBeDefined(); + }); + + // The docked geometry (flush with the window edge) is inspected visually + // in the workbench; jsdom has no layout, so this guards the wiring. + it('opens the surface when docked to an element such as the application bar', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'App launcher' })); + + expect(screen.getByText('Panel content')).toBeDefined(); + }); + + // The docked geometry itself is inspected in the workbench; jsdom has no + // layout, so this asserts that positioning reads the dock target element. + it('positions the surface against the dock target instead of the trigger', async () => { + const { container } = render(); + const bar = container.querySelector('header'); + + if (!bar) { + throw new Error('Expected the shell to render a top bar'); + } + + const barRect = jest.spyOn(bar, 'getBoundingClientRect'); + + fireEvent.click(screen.getByRole('button', { name: 'App launcher' })); + + await waitFor(() => { + expect(barRect).toHaveBeenCalled(); + }); + + barRect.mockRestore(); + }); + + it('opens the surface in full docking mode', () => { + renderLauncher({ docking: 'full' }); + + fireEvent.click(screen.getByRole('button', { name: 'App launcher' })); + + expect(screen.getByText('Panel content')).toBeDefined(); + }); + + it('follows the controlled open prop instead of toggling itself', () => { + const onOpenChange = jest.fn(); + + function ControlledLauncher({ open }: { open: boolean }) { + return ( + + +
Panel content
+
+
+ ); + } + + const { rerender } = render(); + const trigger = screen.getByRole('button', { name: 'App launcher' }); + + fireEvent.click(trigger); + + // The click only asks to open; the controlled prop still says closed. + expect(onOpenChange).toHaveBeenCalledWith(expect.anything(), { + open: true, + }); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(screen.queryByText('Panel content')).toBeNull(); + + rerender(); + + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + expect(screen.getByText('Panel content')).toBeDefined(); + }); + + it('closes the surface on Escape', async () => { + renderLauncherWithFocusablePanel(); + const trigger = screen.getByRole('button', { name: 'App launcher' }); + + fireEvent.click(trigger); + const firstApp = await screen.findByRole('button', { name: 'First app' }); + + // In a real browser, opening moves focus into the surface and Escape + // is handled there; in jsdom Tabster skips that focus move, so focus + // is set manually to exercise the same surface-side handler. + firstApp.focus(); + fireEvent.keyDown(firstApp, { key: 'Escape' }); + + await waitFor(() => { + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(screen.queryByRole('button', { name: 'First app' })).toBeNull(); + }); + }); + + it('closes the surface when clicking outside', async () => { + renderLauncher(); + fireEvent.click(screen.getByRole('button', { name: 'App launcher' })); + expect(screen.getByText('Panel content')).toBeDefined(); + + fireEvent.click(document.body); + + await waitFor(() => { + expect(screen.queryByText('Panel content')).toBeNull(); + }); + }); + + it('shows the trigger tooltip while the surface is closed', async () => { + renderLauncher(); + + fireEvent.focus(screen.getByRole('button', { name: 'App launcher' })); + + expect(await screen.findByRole('tooltip')).toBeDefined(); + }); + + it('hides the trigger tooltip while the surface is open', async () => { + renderLauncher(); + const trigger = screen.getByRole('button', { name: 'App launcher' }); + + fireEvent.click(trigger); + fireEvent.focus(trigger); + + await expect( + screen.findByRole('tooltip', undefined, { timeout: 600 }) + ).rejects.toThrow(); + }); + + it('uses a caller-provided trigger element instead of the built-in button', () => { + renderLauncher({ trigger: }); + + expect(screen.queryByRole('button', { name: 'App launcher' })).toBeNull(); + expect( + screen.getByRole('button', { name: 'Custom trigger' }) + ).toBeDefined(); + }); + + it('opens the surface from a caller-provided trigger', () => { + renderLauncher({ trigger: }); + + fireEvent.click(screen.getByRole('button', { name: 'Custom trigger' })); + + expect(screen.getByText('Panel content')).toBeDefined(); + }); +}); diff --git a/packages/react-app-launcher/src/components/AppLauncher/AppLauncher.tsx b/packages/react-app-launcher/src/components/AppLauncher/AppLauncher.tsx new file mode 100644 index 000000000..75837f234 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncher/AppLauncher.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import type { AppLauncherProps } from './AppLauncher.types'; +import { renderAppLauncher } from './renderAppLauncher'; +import { useAppLauncher } from './useAppLauncher'; + +/** + * Application launcher: the waffle trigger that opens a popover surface. + * + * Compose it with the surface content in the same way a `Popover` is used; + * the launcher adds the trigger, the docking default, and (in later + * iterations) the panel behavior. Pass `trigger` to replace the built-in + * waffle button with your own element; the forwarded ref points at the + * built-in trigger button. + * + * @example + * ```tsx + * …panel content… + * ``` + */ +export const AppLauncher = React.forwardRef< + HTMLButtonElement, + AppLauncherProps +>((props, ref) => { + const state = useAppLauncher(props, ref); + + return renderAppLauncher(state); +}); + +AppLauncher.displayName = 'AppLauncher'; diff --git a/packages/react-app-launcher/src/components/AppLauncher/AppLauncher.types.ts b/packages/react-app-launcher/src/components/AppLauncher/AppLauncher.types.ts new file mode 100644 index 000000000..13e6e32df --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncher/AppLauncher.types.ts @@ -0,0 +1,109 @@ +import type { PopoverProps } from '@fluentui/react-components'; +import type { ReactElement, ReactNode, RefObject } from 'react'; + +/** + * Height behavior of the launcher surface. + * + * - `'default'`: the surface wraps its content, capped at the space between + * its top edge and the bottom of the window. + * - `'full'`: the surface is forced to extend from its anchored top edge down + * to the bottom of the window. + */ +export type AppLauncherDocking = 'default' | 'full'; + +/** + * Props of the app launcher. + * + * The launcher pairs the waffle trigger with a popover surface that renders + * whatever blocks the caller composes inside it: usually a search field, + * sections of tiles and lists of rows. + */ +export type AppLauncherProps = { + /** + * Accessible name of the trigger and its tooltip text. Defaults to + * `'App launcher'`; applications should pass a localized string. + */ + label?: string; + + /** Trigger icon override. Defaults to the waffle glyph. */ + icon?: ReactElement; + + /** + * Caller-provided trigger element, used instead of the built-in waffle + * button. Pass any element that accepts and forwards injected props and a + * ref to its root DOM node — the same contract Fluent's `PopoverTrigger` + * expects — for example a `ToolbarButton`, so the launcher can live inside + * an application toolbar or a drawer header. + * + * The element owns its accessible name (typically `aria-label`) and its + * tooltip: `label`, `icon` and the built-in tooltip handling apply to the + * built-in trigger only, and the forwarded ref of `AppLauncher` points at + * the built-in trigger button. + * + * @example + * ```tsx + * } />} + * > + * …panel content… + * + * ``` + */ + trigger?: ReactElement; + + /** + * Docking of the surface relative to its anchor. Defaults to opening + * below, aligned with the start edge. Combine with `dockTarget` to dock + * to the right edge (`align: 'end'`), and so on. + */ + positioning?: PopoverProps['positioning']; + + /** + * Element the surface anchors to instead of the trigger, for example the + * application's top bar. The surface then docks to that element's edge + * according to `positioning`: a launcher in the top bar docks flush with + * the window's edge, directly below the bar, the way the Microsoft 365 + * app launcher behaves. + * + * The element must stay mounted while the launcher is open. + * + * @example + * ```tsx + * const barRef = useRef(null); + * + *
…
+ * … + * ``` + */ + dockTarget?: RefObject; + + /** + * Height behavior of the surface. Defaults to `'default'`, where the + * surface wraps its content and is capped at the space between its top + * edge and the window bottom: a taller panel scrolls internally instead + * of growing the page. With `'full'`, the surface instead extends from + * its anchored top edge down to the window bottom — combined with + * `dockTarget`, the panel fills the window below the application bar. + * + * Size panel content with `box-sizing: border-box`: with the default + * content box, padding and borders are added on top of the available + * height and make the panel scroll. + */ + docking?: AppLauncherDocking; + + /** + * Whether the surface is open. Leave it undefined for the uncontrolled + * default; pass it together with `onOpenChange` to keep the state in the + * application — for example to close the panel when an app is activated. + */ + open?: boolean; + + /** + * Fired whenever the launcher asks to change the open state: the trigger, + * Escape, or a click outside. With `open` set, apply the change yourself. + */ + onOpenChange?: NonNullable; + + /** Content of the popover surface. */ + children: ReactNode; +}; diff --git a/packages/react-app-launcher/src/components/AppLauncher/index.ts b/packages/react-app-launcher/src/components/AppLauncher/index.ts new file mode 100644 index 000000000..b18dc18b8 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncher/index.ts @@ -0,0 +1,2 @@ +export { AppLauncher } from './AppLauncher'; +export type { AppLauncherDocking, AppLauncherProps } from './AppLauncher.types'; diff --git a/packages/react-app-launcher/src/components/AppLauncher/renderAppLauncher.tsx b/packages/react-app-launcher/src/components/AppLauncher/renderAppLauncher.tsx new file mode 100644 index 000000000..2969fd14b --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncher/renderAppLauncher.tsx @@ -0,0 +1,62 @@ +/** @jsxRuntime classic */ +/** @jsx createElement */ + +import { + Popover, + PopoverSurface, + PopoverTrigger, +} from '@fluentui/react-components'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { createElement } from '@fluentui/react-jsx-runtime'; +import type { ReactElement } from 'react'; +import { AppLauncherTrigger } from '../AppLauncherTrigger'; +import type { AppLauncherState } from './useAppLauncher'; + +/** + * Renders the app launcher: the trigger inside `PopoverTrigger` and the + * caller's content inside `PopoverSurface`. The trigger is the built-in + * waffle button unless the caller passed `trigger`, which then renders in its + * place. + * + * `disableButtonEnhancement` keeps the existing button as the trigger instead + * of letting `PopoverTrigger` wrap it in a second button. Both the built-in + * trigger and a caller-provided element accept and forward injected props and + * refs, which is all the trigger enhancer needs, and it lets `PopoverTrigger` + * merge its own anchor ref with the forwarded one. The built-in trigger's + * tooltip is hidden while the surface is open so the label does not float + * over the panel. + * + * The surface carries `data-app-launcher-surface`, which the style rules of + * this package target to outweigh Fluent's own surface styles: Fluent's + * stylesheet is inserted after this package's, so an equal-specificity rule + * would lose to its defaults. + * + * @param state - resolved launcher state + * @returns the trigger and the popover surface + */ +export function renderAppLauncher(state: AppLauncherState): ReactElement { + return ( + + + {state.trigger ?? ( + + )} + + + {state.children} + + + ); +} diff --git a/packages/react-app-launcher/src/components/AppLauncher/useAppLauncher.ts b/packages/react-app-launcher/src/components/AppLauncher/useAppLauncher.ts new file mode 100644 index 000000000..680ba425d --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncher/useAppLauncher.ts @@ -0,0 +1,151 @@ +import type { + PopoverProps, + PositioningProps, +} from '@fluentui/react-components'; +import { resolvePositioningShorthand } from '@fluentui/react-components'; +import type { ReactNode, Ref } from 'react'; +import { useMemo, useState } from 'react'; +import type { AppLauncherProps } from './AppLauncher.types'; +import type { AppLauncherClasses } from './useAppLauncherStyles.styles'; +import { useAppLauncherStyles } from './useAppLauncherStyles.styles'; + +/** + * Docking used when the caller does not pass `positioning`: the surface opens + * below the anchor, aligned with its start edge, which matches how the waffle + * behaves in a top-left application header. + */ +const DEFAULT_POSITIONING: PositioningProps = { + position: 'below', + align: 'start', +}; + +/** + * State that {@link renderAppLauncher} renders. + */ +export type AppLauncherState = { + /** Accessible name and tooltip text of the trigger. */ + label: AppLauncherProps['label']; + /** Trigger icon override. */ + icon: AppLauncherProps['icon']; + /** Caller-provided trigger element, used instead of the built-in one. */ + trigger: AppLauncherProps['trigger']; + /** + * Resolved docking of the surface. Always an object so the dock target + * can travel inside it, which is where the popover reads its positioning + * options from. + */ + positioning: PositioningProps; + /** + * Whether the surface is open, from the controlled prop when the caller + * passes one and from the internal mirror otherwise. Drives the trigger + * tooltip. + */ + open: boolean; + /** + * The controlled `open` prop, passed to the popover as-is; `undefined` + * keeps the popover uncontrolled. + */ + controlledOpen: AppLauncherProps['open']; + /** Records open state changes; passed to the popover. */ + onOpenChange: NonNullable; + /** Resolved surface style classes. */ + classes: AppLauncherClasses; + /** Caller-provided surface content. */ + children: ReactNode; + /** Ref to the trigger button, forwarded to the applications. */ + triggerRef: Ref; +}; + +/** + * Resolves the launcher props into render state. + * + * All defaulting happens here so that rendering stays declarative: the + * positioning shorthand falls back to opening below the anchor, and shorthand + * strings (`'below-end'` and friends) are resolved to options first, so the + * dock target can be merged in. + * + * When `dockTarget` is set, the surface positions against that element + * instead of the trigger. The element is wrapped in a stable virtual target + * whose `getBoundingClientRect` forwards to the ref on every position + * computation. That indirection matters because the launcher usually renders + * inside the very bar it docks to: the bar's ref is not attached yet while + * the launcher mounts, and reading the ref lazily also keeps the position + * correct when the layout changes later. + * + * The open state lives in the popover unless the caller passes `open`; the + * hook always mirrors it so the trigger can hide its tooltip while the panel + * is open, and forwards every change through `onOpenChange`. + * + * Every docking merges an `autoSize` height option into the positioning: the + * positioning layer then caps the surface at the space between its top edge + * and the window bottom — pinning the height and adding `overflow-y: auto` + * once the content is taller — so a long panel scrolls inside instead of + * growing the page. Full docking additionally asks for the full window height + * through its style. + * + * @param props - props from this instance of the launcher + * @param ref - ref to the trigger button element + * @returns the state consumed by {@link renderAppLauncher} + */ +export function useAppLauncher( + props: AppLauncherProps, + ref: Ref +): AppLauncherState { + const { + children, + docking = 'default', + dockTarget, + icon, + label, + onOpenChange, + open: controlledOpen, + positioning = DEFAULT_POSITIONING, + trigger, + } = props; + + const classes = useAppLauncherStyles(docking); + + const [openState, setOpenState] = useState(false); + + const handleOpenChange: NonNullable = ( + event, + data + ) => { + setOpenState(data.open); + onOpenChange?.(event, data); + }; + + const open = controlledOpen ?? openState; + + // Virtual targets are what the positioning layer uses for targets that + // are not plain elements; Fluent itself builds them for context menus. + const dockTargetElement = useMemo((): + | NonNullable + | undefined => { + if (!dockTarget) { + return undefined; + } + + return { + getBoundingClientRect: () => + dockTarget.current?.getBoundingClientRect() ?? new DOMRect(), + }; + }, [dockTarget]); + + return { + label, + icon, + trigger, + positioning: { + ...resolvePositioningShorthand(positioning), + ...(dockTargetElement ? { target: dockTargetElement } : null), + autoSize: 'height' as const, + }, + open, + controlledOpen, + onOpenChange: handleOpenChange, + classes, + children, + triggerRef: ref, + }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncher/useAppLauncherStyles.styles.ts b/packages/react-app-launcher/src/components/AppLauncher/useAppLauncherStyles.styles.ts new file mode 100644 index 000000000..c6e782e16 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncher/useAppLauncherStyles.styles.ts @@ -0,0 +1,67 @@ +import { makeStyles, tokens } from '@fluentui/react-components'; +import type { AppLauncherDocking } from './AppLauncher.types'; + +/** + * Panel background shared by both docking variants. + * + * Fluent's own surface style wins on equal specificity, because its + * stylesheet is inserted after this package's. The selector therefore + * carries the `data-app-launcher-surface` attribute the render adds, and the + * background stays pinned in one place: currently the Fluent default, + * `colorNeutralBackground1` (white in the light theme). + */ +const panelBackground = { + '&[data-app-launcher-surface]': { + backgroundColor: tokens.colorNeutralBackground1, + }, +} as const; + +const useStyles = makeStyles({ + surface: { + borderRadius: tokens.borderRadiusXLarge, + ...panelBackground, + }, + surfaceFullDock: { + // Asking for the full window height is what makes the positioning + // layer's height clamp — and with it the dock — always apply. + height: '100dvh', + // The full dock matches the default dock's 8px rounding on the + // corners that do not touch the window edge and stays square at the + // bottom, where the panel is flush with the window. + borderTopLeftRadius: tokens.borderRadiusXLarge, + borderTopRightRadius: tokens.borderRadiusXLarge, + borderBottomLeftRadius: '0', + borderBottomRightRadius: '0', + ...panelBackground, + }, +}); + +/** Class names that style parts of the launcher. */ +export type AppLauncherClasses = { + /** Applied to the popover surface. */ + surface: string; +}; + +/** + * Resolves the surface style classes. + * + * The default docking rounds the surface with `borderRadiusXLarge` (8px), the + * radius used by the Microsoft 365 app launcher. Full docking sizes the + * surface instead: it asks for the full window height, rounds only the top + * corners — staying square where the panel meets the window — and the + * positioning layer clamps it to the space below the anchor, scrolling the + * content when it is taller. Both variants carry the panel background from + * one place. + * + * @param docking - resolved docking behavior + * @returns class names for the surface slot + */ +export function useAppLauncherStyles( + docking: AppLauncherDocking +): AppLauncherClasses { + const styles = useStyles(); + + return { + surface: docking === 'full' ? styles.surfaceFullDock : styles.surface, + }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherGrid/AppLauncherGrid.test.tsx b/packages/react-app-launcher/src/components/AppLauncherGrid/AppLauncherGrid.test.tsx new file mode 100644 index 000000000..2e975a2ea --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherGrid/AppLauncherGrid.test.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { AppLauncher, AppLauncherGrid, AppLauncherItem } from '../../index'; + +/** + * Renders a small app grid inside the theme provider, mirroring how + * applications mount the panel content. + */ +function renderGrid() { + return render( + + + + + + + + ); +} + +describe('AppLauncherGrid', () => { + it('renders its children as a list of tiles', () => { + renderGrid(); + + expect(screen.getByRole('list')).toBeDefined(); + expect(screen.getAllByRole('listitem')).toHaveLength(3); + expect(screen.getByRole('button', { name: 'Mail' })).toBeDefined(); + }); + + it('composes inside the launcher surface', () => { + render( + + + + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'App launcher' })); + + expect(screen.getByRole('button', { name: 'Mail' })).toBeDefined(); + }); +}); diff --git a/packages/react-app-launcher/src/components/AppLauncherGrid/AppLauncherGrid.tsx b/packages/react-app-launcher/src/components/AppLauncherGrid/AppLauncherGrid.tsx new file mode 100644 index 000000000..b01c17686 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherGrid/AppLauncherGrid.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import type { AppLauncherGridProps } from './AppLauncherGrid.types'; +import { renderAppLauncherGrid } from './renderAppLauncherGrid'; +import { useAppLauncherGrid } from './useAppLauncherGrid'; + +/** + * App tile grid of the launcher panel. + * + * Lays `AppLauncherItem` children out in three columns by default, the + * Microsoft 365 app launcher layout, and wraps each tile in a list item so + * the panel reads as a list to assistive technology. Tiles are reached with + * Tab in reading order. + * + * @example + * ```tsx + * + * } /> + * } /> + * + * ``` + */ +export const AppLauncherGrid = React.forwardRef< + HTMLUListElement, + AppLauncherGridProps +>((props, ref) => { + const state = useAppLauncherGrid(props, ref); + + return renderAppLauncherGrid(state); +}); + +AppLauncherGrid.displayName = 'AppLauncherGrid'; diff --git a/packages/react-app-launcher/src/components/AppLauncherGrid/AppLauncherGrid.types.ts b/packages/react-app-launcher/src/components/AppLauncherGrid/AppLauncherGrid.types.ts new file mode 100644 index 000000000..8e5dcc66a --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherGrid/AppLauncherGrid.types.ts @@ -0,0 +1,23 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; + +/** + * Props of the app launcher grid. + * + * The grid lays its children out as tiles and wraps each one in a list item, + * so assistive technology announces the panel content as a list of apps. Pass + * `AppLauncherItem` elements as children. All native list props (`className`, + * `aria-*`, event handlers, ...) pass through to the `ul` element. + */ +export type AppLauncherGridProps = Omit< + ComponentPropsWithoutRef<'ul'>, + 'children' +> & { + /** Tiles to lay out; pass `AppLauncherItem` elements. */ + children: ReactNode; + + /** + * Number of tile columns. Defaults to 3, the column count of the + * Microsoft 365 app launcher. + */ + columns?: number; +}; diff --git a/packages/react-app-launcher/src/components/AppLauncherGrid/index.ts b/packages/react-app-launcher/src/components/AppLauncherGrid/index.ts new file mode 100644 index 000000000..05bc954ae --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherGrid/index.ts @@ -0,0 +1,2 @@ +export { AppLauncherGrid } from './AppLauncherGrid'; +export type { AppLauncherGridProps } from './AppLauncherGrid.types'; diff --git a/packages/react-app-launcher/src/components/AppLauncherGrid/renderAppLauncherGrid.tsx b/packages/react-app-launcher/src/components/AppLauncherGrid/renderAppLauncherGrid.tsx new file mode 100644 index 000000000..b86491cf9 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherGrid/renderAppLauncherGrid.tsx @@ -0,0 +1,45 @@ +/** @jsxRuntime classic */ +/** @jsx createElement */ + +import { mergeClasses } from '@fluentui/react-components'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { createElement } from '@fluentui/react-jsx-runtime'; +import type { ReactElement } from 'react'; +import { Children } from 'react'; +import { AppLauncherItemLayoutContext } from '../AppLauncherItem/AppLauncherItemLayoutContext'; +import type { AppLauncherGridState } from './useAppLauncherGrid'; + +/** + * Renders the app launcher grid. + * + * Each child is wrapped in an `
  • `, which gives the panel correct list + * semantics: assistive technology announces the app tiles as a list, and the + * buttons inside them carry the accessible names. The grid also tells every + * item inside it to render as a tile, so a grid nested in a list still shows + * tiles. The column template travels as an inline style because its value is + * computed per instance. + * + * @param state - resolved grid state + * @returns the list that lays out the tiles + */ +export function renderAppLauncherGrid( + state: AppLauncherGridState +): ReactElement { + return ( +
      + + {Children.map(state.children, (child) => ( +
    • {child}
    • + ))} +
      +
    + ); +} diff --git a/packages/react-app-launcher/src/components/AppLauncherGrid/useAppLauncherGrid.ts b/packages/react-app-launcher/src/components/AppLauncherGrid/useAppLauncherGrid.ts new file mode 100644 index 000000000..fb0eb2231 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherGrid/useAppLauncherGrid.ts @@ -0,0 +1,51 @@ +import type { ReactNode, Ref } from 'react'; +import type { AppLauncherGridProps } from './AppLauncherGrid.types'; +import type { AppLauncherGridClasses } from './useAppLauncherGridStyles.styles'; +import { useAppLauncherGridStyles } from './useAppLauncherGridStyles.styles'; + +/** Column count of the Microsoft 365 app launcher grid. */ +const DEFAULT_COLUMNS = 3; + +/** + * State that {@link renderAppLauncherGrid} renders. + */ +export type AppLauncherGridState = { + /** Grid template built from the resolved column count. */ + gridTemplateColumns: string; + /** Remaining native list props forwarded to the root element. */ + rootProps: Omit; + /** Tiles to lay out. */ + children: ReactNode; + /** Ref to the list element, forwarded to the applications. */ + rootRef: Ref; + /** Resolved grid style classes. */ + classes: AppLauncherGridClasses; +}; + +/** + * Resolves the grid props into render state. + * + * The column template uses `minmax(0, 1fr)` so tiles can shrink below their + * content width; without it a long app name would widen the grid column and + * with it the whole panel. + * + * @param props - props from this instance of the grid + * @param ref - ref to the list element + * @returns the state consumed by {@link renderAppLauncherGrid} + */ +export function useAppLauncherGrid( + props: AppLauncherGridProps, + ref: Ref +): AppLauncherGridState { + const { children, columns = DEFAULT_COLUMNS, ...rootProps } = props; + + const classes = useAppLauncherGridStyles(); + + return { + gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, + rootProps, + children, + rootRef: ref, + classes, + }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherGrid/useAppLauncherGridStyles.styles.ts b/packages/react-app-launcher/src/components/AppLauncherGrid/useAppLauncherGridStyles.styles.ts new file mode 100644 index 000000000..8381148ca --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherGrid/useAppLauncherGridStyles.styles.ts @@ -0,0 +1,35 @@ +import { makeStyles, tokens } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + display: 'grid', + // The panel width of the Microsoft 365 app launcher. The grid owns it + // because the surface wraps its content: the widest block inside the + // panel decides how wide the launcher opens. + width: '320px', + gap: tokens.spacingHorizontalXS, + margin: 0, + padding: 0, + listStyleType: 'none', + }, +}); + +/** Class names that style parts of the grid. */ +export type AppLauncherGridClasses = { + /** Applied to the list element. */ + root: string; +}; + +/** + * Resolves the grid style classes. + * + * The grid removes the list decoration the `ul` element brings along and + * fixes the panel width so the launcher does not resize with its content. + * + * @returns class names for the grid slots + */ +export function useAppLauncherGridStyles(): AppLauncherGridClasses { + const styles = useStyles(); + + return { root: styles.root }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.styles.test.tsx b/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.styles.test.tsx new file mode 100644 index 000000000..92c8968b7 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.styles.test.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { Mail32Regular } from '@fluentui/react-icons'; +import { render, screen } from '@testing-library/react'; +import { AppLauncherGrid, AppLauncherItem, AppLauncherList } from '../../index'; + +describe('AppLauncherItem', () => { + it('follows the layout of the surrounding container', () => { + // jsdom has no layout engine, so the class names stand in for the + // visual difference: a grid renders tiles, a list renders rows. + const grid = render( + + + + + + ); + const tileClasses = screen.getByRole('button', { name: 'Mail' }).className; + grid.unmount(); + + render( + + + + + + ); + const rowClasses = screen.getByRole('button', { name: 'Mail' }).className; + + expect(tileClasses).not.toBe(rowClasses); + }); + + it('dims the icon of a disabled item', () => { + // jsdom has no layout engine, so the class names stand in for the + // visual difference: only a disabled item carries the dimming class + // on its icon box. The icon box is the first span inside the button. + const enabled = render( + + } /> + + ); + const enabledIconClasses = screen + .getByRole('button', { name: 'Mail' }) + .querySelector('span')?.className; + enabled.unmount(); + + render( + + } disabled /> + + ); + const disabledIconClasses = screen + .getByRole('button', { name: 'Mail' }) + .querySelector('span')?.className; + + expect(disabledIconClasses).toBeTruthy(); + expect(disabledIconClasses).not.toBe(enabledIconClasses); + }); + + it('dims the icon of a disabledFocusable item exactly as for a disabled item', () => { + // jsdom has no layout engine, so the class names stand in for the + // visual difference: a disabledFocusable item carries the same + // dimming class on its icon box as a native-disabled item. + const disabled = render( + + } disabled /> + + ); + const disabledIconClasses = screen + .getByRole('button', { name: 'Mail' }) + .querySelector('span')?.className; + disabled.unmount(); + + render( + + } + disabledFocusable + /> + + ); + const focusableIconClasses = screen + .getByRole('button', { name: 'Mail' }) + .querySelector('span')?.className; + + expect(focusableIconClasses).toBeTruthy(); + expect(focusableIconClasses).toBe(disabledIconClasses); + }); +}); diff --git a/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.test.tsx b/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.test.tsx new file mode 100644 index 000000000..bde5b11d2 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.test.tsx @@ -0,0 +1,144 @@ +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { Mail32Regular } from '@fluentui/react-icons'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { AppLauncherItem } from '../../index'; + +/** + * Renders the tile inside the theme provider, mirroring how applications + * mount it. + */ +function renderItem(item: ReactElement) { + return render({item}); +} + +describe('AppLauncherItem', () => { + it('renders the app name as the button text', () => { + renderItem(} />); + + const button = screen.getByRole('button', { name: 'Mail' }); + + expect(button.textContent).toBe('Mail'); + expect(button.querySelector('svg')).not.toBeNull(); + }); + + it('renders without an icon', () => { + renderItem(); + + const button = screen.getByRole('button', { name: 'Mail' }); + + expect(button.querySelector('svg')).toBeNull(); + }); + + it('calls onClick when the tile is activated', () => { + const onClick = jest.fn(); + renderItem(); + + fireEvent.click(screen.getByRole('button', { name: 'Mail' })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders a disabled tile as a disabled button', () => { + // The native disabled attribute is what stops activation for users; + // the accessible name stays readable. + renderItem( + } disabled /> + ); + + const button = screen.getByRole('button', { name: 'Bookings' }); + + expect(button.hasAttribute('disabled')).toBe(true); + }); + + it('renders a disabledFocusable tile as an aria-disabled button that stays focusable', () => { + // A disabledFocusable tile relies on `aria-disabled` alone: because + // no native `disabled` attribute is set, keyboard users can still + // reach the tile, and the button reports itself as disabled. + renderItem(); + + const button = screen.getByRole('button', { name: 'Bookings' }); + + expect(button.getAttribute('aria-disabled')).toBe('true'); + expect(button.hasAttribute('disabled')).toBe(false); + + button.focus(); + + expect(document.activeElement).toBe(button); + }); + + it('does not activate a disabledFocusable tile when it is clicked', () => { + const onClick = jest.fn(); + renderItem( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Bookings' })); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('does not activate a disabled tile when it is clicked', () => { + const onClick = jest.fn(); + renderItem(); + + const button = screen.getByRole('button', { name: 'Bookings' }); + + expect(button.hasAttribute('disabled')).toBe(true); + + button.focus(); + + expect(document.activeElement).not.toBe(button); + + fireEvent.click(button); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('lets disabledFocusable win when a tile is both disabled and disabledFocusable', () => { + // A disabledFocusable prop takes precedence over a native disabled + // prop: the tile never carries the native attribute and stays + // focusable, while the accessible state still reads as disabled. + const onClick = jest.fn(); + renderItem( + + ); + + const button = screen.getByRole('button', { name: 'Bookings' }); + + expect(button.getAttribute('aria-disabled')).toBe('true'); + expect(button.hasAttribute('disabled')).toBe(false); + + button.focus(); + + expect(document.activeElement).toBe(button); + + fireEvent.click(button); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('does not activate a disabledFocusable tile when Enter is pressed', () => { + // jsdom does not synthesize a click from Enter, so the key events are + // dispatched directly. The tile must ignore them. + const onClick = jest.fn(); + renderItem( + + ); + + const button = screen.getByRole('button', { name: 'Bookings' }); + + button.focus(); + + fireEvent.keyDown(button, { key: 'Enter' }); + fireEvent.keyUp(button, { key: 'Enter' }); + + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.tsx b/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.tsx new file mode 100644 index 000000000..2ccef7c79 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import type { AppLauncherItemProps } from './AppLauncherItem.types'; +import { renderAppLauncherItem } from './renderAppLauncherItem'; +import { useAppLauncherItem } from './useAppLauncherItem'; + +/** + * One application of the launcher panel. + * + * Renders the app icon and its name in a subtle button, laid out as the + * surrounding container asks: a tile (icon above the name) inside an + * `AppLauncherGrid`, a row (icon beside the name) inside an + * `AppLauncherList`. The item is presentation only: the application decides + * what activation means through `onClick`. + * + * @example + * ```tsx + * } onClick={openMail} /> + * ``` + */ +export const AppLauncherItem = React.forwardRef< + HTMLButtonElement, + AppLauncherItemProps +>((props, ref) => { + const state = useAppLauncherItem(props, ref); + + return renderAppLauncherItem(state); +}); + +AppLauncherItem.displayName = 'AppLauncherItem'; diff --git a/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.types.ts b/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.types.ts new file mode 100644 index 000000000..73e764f3c --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItem.types.ts @@ -0,0 +1,37 @@ +import type { ComponentPropsWithoutRef, ReactElement } from 'react'; + +/** + * Props of an app launcher item. + * + * The item renders the app name as the button text, so the accessible name + * is the visible label and no `aria-label` is needed. The surrounding + * container decides the layout: a tile (icon above the name) inside an + * `AppLauncherGrid`, a row (icon beside the name) inside an `AppLauncherList`. + * All native button props (`onClick`, `disabled`, `className`, ...) pass + * through to the button element. + */ +export type AppLauncherItemProps = Omit< + ComponentPropsWithoutRef<'button'>, + 'children' +> & { + /** Name of the app, shown with the icon and used as the accessible name. */ + label: string; + + /** + * Icon shown with the app name — above it in a grid, beside it in a list + * — for example a sized Fluent icon (``), or an + * application logo element. The item does not constrain the icon's size. + * When omitted, only the label is shown. + */ + icon?: ReactElement; + + /** + * Renders the item disabled but reachable: `aria-disabled` is set instead + * of the native `disabled` attribute, so the item keeps its place in the + * tab order and is announced as disabled while never activating. The item + * shows the same muted look as a `disabled` item. When both `disabled` + * and `disabledFocusable` are set, the Button's own precedence applies: + * the item stays focusable and is announced disabled. + */ + disabledFocusable?: boolean; +}; diff --git a/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItemLayoutContext.ts b/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItemLayoutContext.ts new file mode 100644 index 000000000..3f08e697d --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherItem/AppLauncherItemLayoutContext.ts @@ -0,0 +1,20 @@ +import { createContext } from 'react'; + +/** + * Layout a container block asks its items to render with. + * + * `'tile'` is the vertical grid layout — icon above the app name — and + * `'row'` is the horizontal list layout with the icon beside the name. + */ +export type AppLauncherItemLayout = 'tile' | 'row'; + +/** + * Layout that app launcher items render with. + * + * Container blocks provide their layout: `AppLauncherGrid` provides `'tile'` + * and `AppLauncherList` provides `'row'`, so the innermost container wins for + * the items it wraps. Items outside any container fall back to `'tile'`, the + * layout of the first container block. + */ +export const AppLauncherItemLayoutContext = + createContext('tile'); diff --git a/packages/react-app-launcher/src/components/AppLauncherItem/index.ts b/packages/react-app-launcher/src/components/AppLauncherItem/index.ts new file mode 100644 index 000000000..d389ae264 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherItem/index.ts @@ -0,0 +1,2 @@ +export { AppLauncherItem } from './AppLauncherItem'; +export type { AppLauncherItemProps } from './AppLauncherItem.types'; diff --git a/packages/react-app-launcher/src/components/AppLauncherItem/renderAppLauncherItem.tsx b/packages/react-app-launcher/src/components/AppLauncherItem/renderAppLauncherItem.tsx new file mode 100644 index 000000000..4135454ea --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherItem/renderAppLauncherItem.tsx @@ -0,0 +1,46 @@ +/** @jsxRuntime classic */ +/** @jsx createElement */ + +import { Button, mergeClasses } from '@fluentui/react-components'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { createElement } from '@fluentui/react-jsx-runtime'; +import type { ReactElement } from 'react'; +import type { AppLauncherItemState } from './useAppLauncherItem'; + +/** + * Renders an app launcher tile. + * + * The icon and the app name are rendered as button children rather than + * through the button's `icon` slot: the slot is sized for inline icons that + * match the text height, which a large app logo would fight. The icon also + * gets a box of its own, so the disabled state can dim a colorful logo + * without touching the name. + * + * @param state - resolved tile state + * @returns the tile button + */ +export function renderAppLauncherItem( + state: AppLauncherItemState +): ReactElement { + return ( + + ); +} diff --git a/packages/react-app-launcher/src/components/AppLauncherItem/useAppLauncherItem.ts b/packages/react-app-launcher/src/components/AppLauncherItem/useAppLauncherItem.ts new file mode 100644 index 000000000..5cfce1b37 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherItem/useAppLauncherItem.ts @@ -0,0 +1,53 @@ +import type { ReactElement, Ref } from 'react'; +import { useContext } from 'react'; +import type { AppLauncherItemProps } from './AppLauncherItem.types'; +import { AppLauncherItemLayoutContext } from './AppLauncherItemLayoutContext'; +import type { AppLauncherItemClasses } from './useAppLauncherItemStyles.styles'; +import { useAppLauncherItemStyles } from './useAppLauncherItemStyles.styles'; + +/** + * State that {@link renderAppLauncherItem} renders. + */ +export type AppLauncherItemState = { + /** Resolved app name. */ + label: string; + /** Resolved icon element, when the caller provided one. */ + icon: ReactElement | undefined; + /** Remaining native button props forwarded to the root element. */ + rootProps: Omit; + /** Ref to the button element, forwarded to the applications. */ + buttonRef: Ref; + /** Resolved item style classes for the layout of the surrounding container. */ + classes: AppLauncherItemClasses; +}; + +/** + * Resolves the item props into render state. + * + * The layout comes from the surrounding container — a tile inside an + * `AppLauncherGrid`, a row inside an `AppLauncherList` — and all defaulting + * happens here so that rendering stays declarative. The item deliberately + * has no appearance prop: its subtle button style is part of the launcher + * look, the same way the trigger defaults to a subtle button. + * + * @param props - props from this instance of the item + * @param ref - ref to the button element + * @returns the state consumed by {@link renderAppLauncherItem} + */ +export function useAppLauncherItem( + props: AppLauncherItemProps, + ref: Ref +): AppLauncherItemState { + const { icon, label, ...rootProps } = props; + + const layout = useContext(AppLauncherItemLayoutContext); + const classes = useAppLauncherItemStyles(layout); + + return { + label, + icon, + rootProps, + buttonRef: ref, + classes, + }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherItem/useAppLauncherItemStyles.styles.ts b/packages/react-app-launcher/src/components/AppLauncherItem/useAppLauncherItemStyles.styles.ts new file mode 100644 index 000000000..6bf86e6c1 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherItem/useAppLauncherItemStyles.styles.ts @@ -0,0 +1,166 @@ +import { makeStyles, tokens } from '@fluentui/react-components'; +import type { AppLauncherItemLayout } from './AppLauncherItemLayoutContext'; + +const useStyles = makeStyles({ + rootTile: { + // Icon above the app name, the vertical tile layout of the Microsoft + // 365 app launcher. + flexDirection: 'column', + gap: tokens.spacingVerticalSNudge, + width: '100%', + height: '100%', + minWidth: '0', + minHeight: '88px', + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXS}`, + // The tiles read as white cards, the way the Microsoft 365 tiles do: + // white and flat at rest — no border, no shadow — with a soft hover + // lift. The lift sits in the middle of the standard shadow scale + // (`shadow16`); the largest Fluent tokens (including the one closest + // to the shadow measured in the Microsoft 365 launcher) read far too + // heavy on tiles this size. `borderRadiusLarge` matches the 6px tile + // radius of the Microsoft 365 tiles. The rules carry `:enabled` to + // outweigh the `appearance="subtle"` styles of the button — at equal + // specificity Fluent's stylesheet wins, because it is inserted after + // this package's. The hovered tile also lifts above its neighbors + // (`position` and `zIndex`): a shadow is painted together with the + // tile, so the neighbors rendering later — the ones to the right and + // below — would cover their share of it and leave the shadow + // lopsided. A disabled tile matches none of the guarded rules, so it + // keeps the button's own disabled look — a muted name and icon, no + // hover reaction, a `not-allowed` cursor — which an application + // toggles from business rules through the `disabled` prop. A + // `disabledFocusable` item has no native attribute, so the guards skip + // `[aria-disabled="true"]` as well and give it the same muted look. The + // enabled states pin the foreground color so the name stays stable + // while the button is hovered or pressed, and the hover shadow steps + // aside for the keyboard focus ring, which Fluent draws as an inset + // shadow on the same element. + ':enabled:not([aria-disabled="true"])': { + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: tokens.borderRadiusLarge, + color: tokens.colorNeutralForeground1, + }, + ':hover:enabled:not([aria-disabled="true"])': { + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + position: 'relative', + zIndex: 1, + }, + ':hover:enabled:active:not([aria-disabled="true"])': { + backgroundColor: tokens.colorSubtleBackgroundPressed, + color: tokens.colorNeutralForeground1, + }, + ':hover:enabled:not([aria-disabled="true"]):not([data-fui-focus-visible])': + { + boxShadow: tokens.shadow16, + }, + }, + rootRow: { + // Icon beside the app name, the horizontal row layout the list + // container asks for. + flexDirection: 'row', + justifyContent: 'flex-start', + gap: tokens.spacingHorizontalS, + width: '100%', + minWidth: '0', + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`, + // Same color handling as the tile: the enabled states pin the + // foreground color so the name does not shift on hover, and a + // disabled row keeps the button's muted disabled look. + ':enabled:not([aria-disabled="true"])': { + color: tokens.colorNeutralForeground1, + }, + ':hover:enabled:not([aria-disabled="true"])': { + color: tokens.colorNeutralForeground1, + }, + ':hover:enabled:active:not([aria-disabled="true"])': { + color: tokens.colorNeutralForeground1, + }, + }, + labelTile: { + // The name follows the button color, so it turns muted together + // with a disabled tile; the enabled states pin that color on the + // button, which keeps the name stable through hover and press. + color: 'inherit', + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightRegular, + lineHeight: tokens.lineHeightBase200, + textAlign: 'center', + overflowWrap: 'anywhere', + }, + labelRow: { + // Follows the button color like the tile label does, so a disabled + // row shows a muted name. + color: 'inherit', + fontSize: tokens.fontSizeBase300, + fontWeight: tokens.fontWeightRegular, + lineHeight: tokens.lineHeightBase300, + textAlign: 'left', + // A long name ends in an ellipsis instead of wrapping, the way list + // rows read in the Microsoft 365 app launcher. `minWidth` lets the + // label shrink below its content width so the ellipsis can appear. + minWidth: '0', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + icon: { + // The icon sits in a box of its own so the disabled state can dim a + // colorful logo. The box is a flex container: it hugs the icon at + // its intrinsic size and avoids the extra space an inline image + // would leave below the text baseline. + display: 'flex', + }, + iconDisabled: { + // Disabled items dim a colorful logo the way the disabled styles + // mute the name: the logo turns gray (`grayscale`) and lightens + // (`opacity`) until it sits near the lightness of the disabled + // text. A monochrome icon already draws with the text color and + // just lightens a little further. + filter: 'grayscale(1)', + opacity: 0.5, + }, +}); + +/** Class names that style parts of an item in its resolved layout. */ +export type AppLauncherItemClasses = { + /** Applied to the item button. */ + root: string; + /** Applied to the app name. */ + label: string; + /** Applied to the box around the app icon. */ + icon: string; + /** Applied to the icon box while the item is disabled. */ + iconDisabled: string; +}; + +/** + * Resolves the item style classes for a layout. + * + * The tile layout stacks the icon above the app name and keeps a fixed + * minimum height so tiles in a row stay aligned when a name wraps onto a + * second line. The row layout puts the icon beside the name and truncates a + * long name with an ellipsis. + * + * @param layout - layout the surrounding container resolved for the item + * @returns class names for the item slots + */ +export function useAppLauncherItemStyles( + layout: AppLauncherItemLayout +): AppLauncherItemClasses { + const styles = useStyles(); + + return layout === 'row' + ? { + root: styles.rootRow, + label: styles.labelRow, + icon: styles.icon, + iconDisabled: styles.iconDisabled, + } + : { + root: styles.rootTile, + label: styles.labelTile, + icon: styles.icon, + iconDisabled: styles.iconDisabled, + }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherList/AppLauncherList.test.tsx b/packages/react-app-launcher/src/components/AppLauncherList/AppLauncherList.test.tsx new file mode 100644 index 000000000..f645caf4e --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherList/AppLauncherList.test.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { AppLauncher, AppLauncherItem, AppLauncherList } from '../../index'; + +/** + * Renders a small app list inside the theme provider, mirroring how + * applications mount the panel content. + */ +function renderList() { + return render( + + + + + + + + ); +} + +describe('AppLauncherList', () => { + it('renders its children as a list of rows', () => { + renderList(); + + expect(screen.getByRole('list')).toBeDefined(); + expect(screen.getAllByRole('listitem')).toHaveLength(3); + expect(screen.getByRole('button', { name: 'Mail' })).toBeDefined(); + }); + + it('composes inside the launcher surface', () => { + render( + + + + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'App launcher' })); + + expect(screen.getByRole('button', { name: 'Mail' })).toBeDefined(); + }); +}); diff --git a/packages/react-app-launcher/src/components/AppLauncherList/AppLauncherList.tsx b/packages/react-app-launcher/src/components/AppLauncherList/AppLauncherList.tsx new file mode 100644 index 000000000..c204a89c8 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherList/AppLauncherList.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import type { AppLauncherListProps } from './AppLauncherList.types'; +import { renderAppLauncherList } from './renderAppLauncherList'; +import { useAppLauncherList } from './useAppLauncherList'; + +/** + * Vertical list of application rows inside the launcher panel. + * + * Lays `AppLauncherItem` children out as full-width rows — the icon beside + * the app name — the layout the search results of the Microsoft 365 app + * launcher use. The list owns the panel width the same way the grid does. + * + * @example + * ```tsx + * + * } onClick={openMail} /> + * + * ``` + */ +export const AppLauncherList = React.forwardRef< + HTMLUListElement, + AppLauncherListProps +>((props, ref) => { + const state = useAppLauncherList(props, ref); + + return renderAppLauncherList(state); +}); + +AppLauncherList.displayName = 'AppLauncherList'; diff --git a/packages/react-app-launcher/src/components/AppLauncherList/AppLauncherList.types.ts b/packages/react-app-launcher/src/components/AppLauncherList/AppLauncherList.types.ts new file mode 100644 index 000000000..87f8300b2 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherList/AppLauncherList.types.ts @@ -0,0 +1,17 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; + +/** + * Props of an app launcher list. + * + * The list stacks its children as full-width rows — the icon beside the app + * name — the layout the search results of the Microsoft 365 app launcher + * use. All native list props (`className`, `aria-*`, event handlers, ...) + * pass through to the `ul` element. + */ +export type AppLauncherListProps = Omit< + ComponentPropsWithoutRef<'ul'>, + 'children' +> & { + /** Rows to lay out, usually `AppLauncherItem` elements. */ + children: ReactNode; +}; diff --git a/packages/react-app-launcher/src/components/AppLauncherList/index.ts b/packages/react-app-launcher/src/components/AppLauncherList/index.ts new file mode 100644 index 000000000..2778ccae8 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherList/index.ts @@ -0,0 +1,2 @@ +export { AppLauncherList } from './AppLauncherList'; +export type { AppLauncherListProps } from './AppLauncherList.types'; diff --git a/packages/react-app-launcher/src/components/AppLauncherList/renderAppLauncherList.tsx b/packages/react-app-launcher/src/components/AppLauncherList/renderAppLauncherList.tsx new file mode 100644 index 000000000..badfa682d --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherList/renderAppLauncherList.tsx @@ -0,0 +1,39 @@ +/** @jsxRuntime classic */ +/** @jsx createElement */ + +import { mergeClasses } from '@fluentui/react-components'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { createElement } from '@fluentui/react-jsx-runtime'; +import type { ReactElement } from 'react'; +import { Children } from 'react'; +import { AppLauncherItemLayoutContext } from '../AppLauncherItem/AppLauncherItemLayoutContext'; +import type { AppLauncherListState } from './useAppLauncherList'; + +/** + * Renders the app launcher list. + * + * Each child is wrapped in an `
  • `, which gives the panel correct list + * semantics: assistive technology announces the rows as a list, and the + * buttons inside them carry the accessible names. The list also tells every + * item inside it to render as a row. + * + * @param state - resolved list state + * @returns the list that stacks the rows + */ +export function renderAppLauncherList( + state: AppLauncherListState +): ReactElement { + return ( +
      + + {Children.map(state.children, (child) => ( +
    • {child}
    • + ))} +
      +
    + ); +} diff --git a/packages/react-app-launcher/src/components/AppLauncherList/useAppLauncherList.ts b/packages/react-app-launcher/src/components/AppLauncherList/useAppLauncherList.ts new file mode 100644 index 000000000..1840978bb --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherList/useAppLauncherList.ts @@ -0,0 +1,45 @@ +import type { ReactNode, Ref } from 'react'; +import type { AppLauncherListProps } from './AppLauncherList.types'; +import type { AppLauncherListClasses } from './useAppLauncherListStyles.styles'; +import { useAppLauncherListStyles } from './useAppLauncherListStyles.styles'; + +/** + * State that {@link renderAppLauncherList} renders. + */ +export type AppLauncherListState = { + /** Remaining native list props forwarded to the root element. */ + rootProps: Omit; + /** Rows to lay out. */ + children: ReactNode; + /** Ref to the list element, forwarded to the applications. */ + rootRef: Ref; + /** Resolved list style classes. */ + classes: AppLauncherListClasses; +}; + +/** + * Resolves the list props into render state. + * + * There is no defaulting to do yet; the hook keeps the block shape of the + * grid and leaves room for list options, such as a maximum height, to land + * in one place. + * + * @param props - props from this instance of the list + * @param ref - ref to the list element + * @returns the state consumed by {@link renderAppLauncherList} + */ +export function useAppLauncherList( + props: AppLauncherListProps, + ref: Ref +): AppLauncherListState { + const { children, ...rootProps } = props; + + const classes = useAppLauncherListStyles(); + + return { + rootProps, + children, + rootRef: ref, + classes, + }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherList/useAppLauncherListStyles.styles.ts b/packages/react-app-launcher/src/components/AppLauncherList/useAppLauncherListStyles.styles.ts new file mode 100644 index 000000000..0141092a0 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherList/useAppLauncherListStyles.styles.ts @@ -0,0 +1,36 @@ +import { makeStyles, tokens } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + // The panel width of the Microsoft 365 app launcher, matching the + // grid so switching between the grid and the list does not resize + // the panel. + width: '320px', + gap: tokens.spacingVerticalXXS, + margin: 0, + padding: 0, + listStyleType: 'none', + }, +}); + +/** Class names that style parts of the list. */ +export type AppLauncherListClasses = { + /** Applied to the list element. */ + root: string; +}; + +/** + * Resolves the list style classes. + * + * The list removes the list decoration the `ul` element brings along and + * fixes the panel width so the launcher does not resize with its content. + * + * @returns class names for the list slots + */ +export function useAppLauncherListStyles(): AppLauncherListClasses { + const styles = useStyles(); + + return { root: styles.root }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherSearch/AppLauncherSearch.test.tsx b/packages/react-app-launcher/src/components/AppLauncherSearch/AppLauncherSearch.test.tsx new file mode 100644 index 000000000..7ef3e6872 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSearch/AppLauncherSearch.test.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { AppLauncher, AppLauncherSearch } from '../../index'; + +/** + * Controlled search field wired to component state, the smallest wiring an + * application needs to filter its own app list. + */ +function ControlledSearch() { + const [query, setQuery] = useState('mail'); + + return ( + setQuery(data.value)} + /> + ); +} + +/** + * Narrows an element found by role to the input the field renders. + * + * @param element - element found by role + * @returns the element as an input element + */ +function asInput(element: HTMLElement): HTMLInputElement { + if (!(element instanceof HTMLInputElement)) { + throw new Error('Expected the search field to render an input element'); + } + + return element; +} + +describe('AppLauncherSearch', () => { + it('renders a labelled search field with the launcher placeholder', () => { + render( + + + + ); + + const field = asInput( + screen.getByRole('searchbox', { name: 'Search apps' }) + ); + + expect(field.placeholder).toBe('Search'); + }); + + it('reports the typed query through onChange', () => { + const onChange = jest.fn(); + + render( + + + + ); + + fireEvent.change(screen.getByRole('searchbox'), { + target: { value: 'mail' }, + }); + + expect(onChange).toHaveBeenCalledWith(expect.anything(), { value: 'mail' }); + }); + + it('clears the query on the first Escape and closes the panel on the second', async () => { + render( + + + + + + ); + + const trigger = screen.getByRole('button', { name: 'App launcher' }); + fireEvent.click(trigger); + const field = asInput( + await screen.findByRole('searchbox', { name: 'Search apps' }) + ); + + expect(field.value).toBe('mail'); + + // jsdom skips the browser focus move into the surface, so focus is + // set manually the way the panel Escape tests do it. + field.focus(); + fireEvent.keyDown(field, { key: 'Escape' }); + + // The clearing Escape must not reach the surface's close handler: + // the field empties and the panel stays open. + expect(field.value).toBe(''); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + + fireEvent.keyDown(field, { key: 'Escape' }); + + await waitFor(() => { + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(screen.queryByRole('searchbox')).toBeNull(); + }); + }); + + it('stops a clearing Escape from reaching handlers outside the field', () => { + const outerKeyDown = jest.fn(); + document.addEventListener('keydown', outerKeyDown); + + try { + render( + + + + ); + + const field = screen.getByRole('searchbox', { name: 'Search apps' }); + + fireEvent.keyDown(field, { key: 'Escape' }); + expect(outerKeyDown).not.toHaveBeenCalled(); + + // Once the field is empty there is nothing to clear, so the next + // Escape travels on to outer handlers, the way it reaches the + // popover surface and closes the panel. + fireEvent.keyDown(field, { key: 'Escape' }); + expect(outerKeyDown).toHaveBeenCalledTimes(1); + } finally { + document.removeEventListener('keydown', outerKeyDown); + } + }); +}); diff --git a/packages/react-app-launcher/src/components/AppLauncherSearch/AppLauncherSearch.tsx b/packages/react-app-launcher/src/components/AppLauncherSearch/AppLauncherSearch.tsx new file mode 100644 index 000000000..2d481c54e --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSearch/AppLauncherSearch.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import type { AppLauncherSearchProps } from './AppLauncherSearch.types'; +import { renderAppLauncherSearch } from './renderAppLauncherSearch'; +import { useAppLauncherSearch } from './useAppLauncherSearch'; + +/** + * Search field of the launcher panel. + * + * A Fluent `SearchBox` with the launcher defaults: the field spans the panel, + * and Escape clears a controlled query before the panel may close — the + * first Escape empties the field, the next one lets the surface close. + * Filtering and switching the panel content stay application concerns; + * render the field above whatever the query should change. + * + * @example + * ```tsx + * const [query, setQuery] = useState(''); + * + * setQuery(data.value)} + * /> + * ``` + */ +export const AppLauncherSearch = React.forwardRef< + HTMLInputElement, + AppLauncherSearchProps +>((props, ref) => { + const state = useAppLauncherSearch(props, ref); + + return renderAppLauncherSearch(state); +}); + +AppLauncherSearch.displayName = 'AppLauncherSearch'; diff --git a/packages/react-app-launcher/src/components/AppLauncherSearch/AppLauncherSearch.types.ts b/packages/react-app-launcher/src/components/AppLauncherSearch/AppLauncherSearch.types.ts new file mode 100644 index 000000000..b18b2d3d6 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSearch/AppLauncherSearch.types.ts @@ -0,0 +1,37 @@ +import type { SearchBoxProps } from '@fluentui/react-components'; +import type { SyntheticEvent } from 'react'; + +/** + * Payload of a query change. + */ +export type AppLauncherSearchChangeData = { + /** The next value of the search field. */ + value: string; +}; + +/** + * Props of the app launcher search field. + * + * The field is a Fluent `SearchBox` with the launcher defaults: it spans the + * panel, shows the search icon and a clear button while a query is present, + * and Escape clears a controlled query before the panel is allowed to close + * (see `onChange`). All remaining SearchBox props (`size`, `appearance`, + * `placeholder`, `disabled`, ...) pass through. `onKeyDown` is owned by the + * block, because that is where the Escape behavior lives. + */ +export type AppLauncherSearchProps = Omit< + SearchBoxProps, + 'aria-label' | 'onChange' | 'onKeyDown' +> & { + /** Accessible name of the field. The launcher shows no visible label. */ + label: string; + + /** + * Fired with the next query value whenever the query changes: while + * typing, when the clear button is clicked, and when Escape clears a + * controlled field. The event is the common `SyntheticEvent` shape + * rather than Fluent's event union, because a clear can arrive from a + * keyboard event while typing arrives from a change event. + */ + onChange?: (event: SyntheticEvent, data: AppLauncherSearchChangeData) => void; +}; diff --git a/packages/react-app-launcher/src/components/AppLauncherSearch/index.ts b/packages/react-app-launcher/src/components/AppLauncherSearch/index.ts new file mode 100644 index 000000000..770422fe1 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSearch/index.ts @@ -0,0 +1,5 @@ +export { AppLauncherSearch } from './AppLauncherSearch'; +export type { + AppLauncherSearchChangeData, + AppLauncherSearchProps, +} from './AppLauncherSearch.types'; diff --git a/packages/react-app-launcher/src/components/AppLauncherSearch/renderAppLauncherSearch.tsx b/packages/react-app-launcher/src/components/AppLauncherSearch/renderAppLauncherSearch.tsx new file mode 100644 index 000000000..940e93100 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSearch/renderAppLauncherSearch.tsx @@ -0,0 +1,39 @@ +/** @jsxRuntime classic */ +/** @jsx createElement */ + +import { mergeClasses, SearchBox } from '@fluentui/react-components'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { createElement } from '@fluentui/react-jsx-runtime'; +import type { ReactElement } from 'react'; +import type { AppLauncherSearchState } from './useAppLauncherSearch'; + +/** + * Renders the app launcher search field. + * + * The field is a Fluent SearchBox inside a sticky wrapper: the wrapper pins + * the field to the panel's top while the content scrolls beneath, and its + * opaque background covers the surface's padding band so no content shows + * above the field. Keeping that band on the wrapper leaves the SearchBox at + * its native size — padding on the field itself would stretch its outline. + * + * @param state - resolved search field state + * @returns the sticky search field wrapper + */ +export function renderAppLauncherSearch( + state: AppLauncherSearchState +): ReactElement { + return ( +
    + +
    + ); +} diff --git a/packages/react-app-launcher/src/components/AppLauncherSearch/useAppLauncherSearch.ts b/packages/react-app-launcher/src/components/AppLauncherSearch/useAppLauncherSearch.ts new file mode 100644 index 000000000..1df6e46c3 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSearch/useAppLauncherSearch.ts @@ -0,0 +1,89 @@ +import type { SearchBoxProps } from '@fluentui/react-components'; +import type { KeyboardEventHandler, Ref } from 'react'; +import type { AppLauncherSearchProps } from './AppLauncherSearch.types'; +import type { AppLauncherSearchClasses } from './useAppLauncherSearchStyles.styles'; +import { useAppLauncherSearchStyles } from './useAppLauncherSearchStyles.styles'; + +/** Placeholder the field shows while no query is present. */ +const DEFAULT_PLACEHOLDER = 'Search'; + +/** + * State that {@link renderAppLauncherSearch} renders. + */ +export type AppLauncherSearchState = { + /** Accessible name of the field. */ + label: string; + /** Visible placeholder of the field. */ + placeholder: string; + /** Current controlled query, when the caller drives the field. */ + value: SearchBoxProps['value']; + /** Caller-provided query change handler, forwarded to the field. */ + onChange: AppLauncherSearchProps['onChange']; + /** Escape handling that keeps the panel open while clearing a query. */ + handleKeyDown: KeyboardEventHandler; + /** Remaining SearchBox props forwarded to the field. */ + rootProps: Omit< + AppLauncherSearchProps, + 'label' | 'onChange' | 'placeholder' | 'value' + >; + /** Ref to the input element, forwarded to the applications. */ + inputRef: Ref; + /** Resolved search field style classes. */ + classes: AppLauncherSearchClasses; +}; + +/** + * Resolves the search field props into render state. + * + * The hook owns one behavior beyond forwarding: Escape inside the field must + * clear a controlled query before the popover surface sees the key and + * closes. Fluent closes the surface on Escape unconditionally, and its own + * source says that stopping the propagation is the supported opt-out. So: + * with a non-empty controlled `value`, the first Escape clears the field and + * stops the event; with an empty or uncontrolled field there is nothing to + * clear, the key travels on, and the panel closes as usual. + * + * @param props - props from this instance of the search field + * @param ref - ref to the input element + * @returns the state consumed by {@link renderAppLauncherSearch} + */ +export function useAppLauncherSearch( + props: AppLauncherSearchProps, + ref: Ref +): AppLauncherSearchState { + const { + label, + onChange, + placeholder = DEFAULT_PLACEHOLDER, + value, + ...rootProps + } = props; + + const classes = useAppLauncherSearchStyles(); + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key !== 'Escape' || event.defaultPrevented) { + return; + } + + if (value === undefined || value === '') { + // Nothing to clear: let the surface handle Escape and close. + return; + } + + event.preventDefault(); + event.stopPropagation(); + onChange?.(event, { value: '' }); + }; + + return { + label, + placeholder, + value, + onChange, + handleKeyDown, + rootProps, + inputRef: ref, + classes, + }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherSearch/useAppLauncherSearchStyles.styles.ts b/packages/react-app-launcher/src/components/AppLauncherSearch/useAppLauncherSearchStyles.styles.ts new file mode 100644 index 000000000..cad01a1c1 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSearch/useAppLauncherSearchStyles.styles.ts @@ -0,0 +1,63 @@ +import { makeStyles, tokens } from '@fluentui/react-components'; + +/** + * Padding of the launcher's `PopoverSurface` (`size="medium"`, Fluent's + * default). The sticky field wrapper compensates it, so its opaque background + * covers the surface's padding band instead of leaving it exposed. + */ +const surfacePadding = '16px'; + +const useStyles = makeStyles({ + root: { + // The wrapper pins the field to the panel's top while the content + // scrolls under it (the surface is the scroll container). A sticky child + // pins below the surface's content padding, where scrolled content would + // show in the band above the field; the wrapper reaches up over that + // band instead — the matching negative margin and sticky offset plus its + // own top padding keep the field in place while its opaque background + // covers the panel's top band across its width. The z-index keeps the + // wrapper above the hover-lifted tiles, which paint at z-index 1. + // + // The band compensation lives on this wrapper rather than on the + // SearchBox itself: Fluent draws the field's outline on the SearchBox's + // own root element, so padding applied there would stretch the visible + // box and push its text to the bottom edge. + position: 'sticky', + top: `-${surfacePadding}`, + marginTop: `-${surfacePadding}`, + paddingTop: surfacePadding, + zIndex: 2, + backgroundColor: tokens.colorNeutralBackground1, + }, + field: { + // The field spans the panel. The surface wraps its content, so the + // widest block inside the panel decides how wide the launcher opens — + // the search box must fill that width, not become it. + width: '100%', + }, +}); + +/** Class names that style parts of the search field. */ +export type AppLauncherSearchClasses = { + /** Applied to the sticky wrapper around the search field. */ + root: string; + /** Applied to the search field itself. */ + field: string; +}; + +/** + * Resolves the search field style classes. + * + * The wrapper pins the field to the panel's top — covering the surface's + * padding band so no scrolled content shows above the field — while the + * content scrolls under it; inert while the content fits. The field itself + * keeps the Fluent SearchBox defaults: outline, search icon, clear button, + * and its native height. + * + * @returns class names for the search field slots + */ +export function useAppLauncherSearchStyles(): AppLauncherSearchClasses { + const styles = useStyles(); + + return { root: styles.root, field: styles.field }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherSection/AppLauncherSection.test.tsx b/packages/react-app-launcher/src/components/AppLauncherSection/AppLauncherSection.test.tsx new file mode 100644 index 000000000..f6a4ace8d --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSection/AppLauncherSection.test.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { render, screen } from '@testing-library/react'; +import { + AppLauncherGrid, + AppLauncherItem, + AppLauncherSection, +} from '../../index'; + +describe('AppLauncherSection', () => { + it('renders its title and content', () => { + render( + + + + + + + + ); + + expect(screen.getByText('All apps')).toBeDefined(); + expect(screen.getByRole('button', { name: 'Mail' })).toBeDefined(); + }); + + it('renders its content without a title', () => { + render( + + + + + + + + ); + + expect(screen.getByRole('button', { name: 'Mail' })).toBeDefined(); + }); +}); diff --git a/packages/react-app-launcher/src/components/AppLauncherSection/AppLauncherSection.tsx b/packages/react-app-launcher/src/components/AppLauncherSection/AppLauncherSection.tsx new file mode 100644 index 000000000..ca343e81d --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSection/AppLauncherSection.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import type { AppLauncherSectionProps } from './AppLauncherSection.types'; +import { renderAppLauncherSection } from './renderAppLauncherSection'; +import { useAppLauncherSection } from './useAppLauncherSection'; + +/** + * Group of app tiles inside the launcher panel. + * + * Wraps a content block — usually an `AppLauncherGrid` — under an optional + * title, and separates itself from the previous section with spacing and, + * when `divider` is set, a divider line. The layout stays composable: an + * untitled section is just a block, and grids work without a section. + * + * @example + * ```tsx + * + * …tiles… + * + * ``` + */ +export const AppLauncherSection = React.forwardRef< + HTMLDivElement, + AppLauncherSectionProps +>((props, ref) => { + const state = useAppLauncherSection(props, ref); + + return renderAppLauncherSection(state); +}); + +AppLauncherSection.displayName = 'AppLauncherSection'; diff --git a/packages/react-app-launcher/src/components/AppLauncherSection/AppLauncherSection.types.ts b/packages/react-app-launcher/src/components/AppLauncherSection/AppLauncherSection.types.ts new file mode 100644 index 000000000..63f2935c1 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSection/AppLauncherSection.types.ts @@ -0,0 +1,30 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; + +/** + * Props of an app launcher section. + * + * A section groups related tiles under an optional title. Sections that + * follow another section are separated by spacing, and optionally by a + * divider line when `divider` is set. All native div props (`className`, + * `aria-*`, event handlers, ...) pass through to the section element. + */ +export type AppLauncherSectionProps = Omit< + ComponentPropsWithoutRef<'div'>, + 'children' | 'title' +> & { + /** Tiles of this section, usually an `AppLauncherGrid`. */ + children: ReactNode; + + /** + * Section title shown above the content, for example "Pinned" or + * "All apps". When omitted, the section renders only its content and the + * spacing above it. + */ + title?: string; + + /** + * Draws a divider line above the section, between it and the previous + * section. Defaults to `false`. + */ + divider?: boolean; +}; diff --git a/packages/react-app-launcher/src/components/AppLauncherSection/index.ts b/packages/react-app-launcher/src/components/AppLauncherSection/index.ts new file mode 100644 index 000000000..f1379b29b --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSection/index.ts @@ -0,0 +1,2 @@ +export { AppLauncherSection } from './AppLauncherSection'; +export type { AppLauncherSectionProps } from './AppLauncherSection.types'; diff --git a/packages/react-app-launcher/src/components/AppLauncherSection/renderAppLauncherSection.tsx b/packages/react-app-launcher/src/components/AppLauncherSection/renderAppLauncherSection.tsx new file mode 100644 index 000000000..b89459148 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSection/renderAppLauncherSection.tsx @@ -0,0 +1,39 @@ +/** @jsxRuntime classic */ +/** @jsx createElement */ + +import { mergeClasses } from '@fluentui/react-components'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { createElement } from '@fluentui/react-jsx-runtime'; +import type { ReactElement } from 'react'; +import type { AppLauncherSectionState } from './useAppLauncherSection'; + +/** + * Renders an app launcher section. + * + * The title is a plain label above the content; grouping semantics for + * assistive technology are deliberately left to the content block, so the + * section can hold a grid, a list, or anything else. + * + * @param state - resolved section state + * @returns the section element with an optional title and its content + */ +export function renderAppLauncherSection( + state: AppLauncherSectionState +): ReactElement { + return ( +
    + {state.title ? ( +
    {state.title}
    + ) : null} + {state.children} +
    + ); +} diff --git a/packages/react-app-launcher/src/components/AppLauncherSection/useAppLauncherSection.ts b/packages/react-app-launcher/src/components/AppLauncherSection/useAppLauncherSection.ts new file mode 100644 index 000000000..b1b02b475 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSection/useAppLauncherSection.ts @@ -0,0 +1,51 @@ +import type { ReactNode, Ref } from 'react'; +import type { AppLauncherSectionProps } from './AppLauncherSection.types'; +import type { AppLauncherSectionClasses } from './useAppLauncherSectionStyles.styles'; +import { useAppLauncherSectionStyles } from './useAppLauncherSectionStyles.styles'; + +/** + * State that {@link renderAppLauncherSection} renders. + */ +export type AppLauncherSectionState = { + /** Resolved section title, when the caller provided one. */ + title: string | undefined; + /** Whether the section draws a divider above itself. */ + divider: boolean; + /** Remaining native div props forwarded to the root element. */ + rootProps: Omit; + /** Section content. */ + children: ReactNode; + /** Ref to the section element, forwarded to the applications. */ + rootRef: Ref; + /** Resolved section style classes. */ + classes: AppLauncherSectionClasses; +}; + +/** + * Resolves the section props into render state. + * + * All defaulting happens here so that rendering stays declarative: the + * divider is off unless the caller asks for it, and a missing title makes the + * section render its content alone. + * + * @param props - props from this instance of the section + * @param ref - ref to the section element + * @returns the state consumed by {@link renderAppLauncherSection} + */ +export function useAppLauncherSection( + props: AppLauncherSectionProps, + ref: Ref +): AppLauncherSectionState { + const { children, divider = false, title, ...rootProps } = props; + + const classes = useAppLauncherSectionStyles(); + + return { + title, + divider, + rootProps, + children, + rootRef: ref, + classes, + }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherSection/useAppLauncherSectionStyles.styles.ts b/packages/react-app-launcher/src/components/AppLauncherSection/useAppLauncherSectionStyles.styles.ts new file mode 100644 index 000000000..d84e68f98 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherSection/useAppLauncherSectionStyles.styles.ts @@ -0,0 +1,62 @@ +import { makeStyles, tokens } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + // Space between stacked sections, but not above the first one, where + // the surface padding already provides the gap. + ':not(:first-child)': { + marginBlockStart: tokens.spacingVerticalL, + }, + }, + rootWithDivider: { + ':not(:first-child)': { + paddingBlockStart: tokens.spacingVerticalL, + // Longhands on purpose: Griffel's style types reject the + // `borderBlockStart` shorthand, and the logical form keeps the + // divider correct in vertical writing modes. + borderBlockStartWidth: tokens.strokeWidthThin, + borderBlockStartStyle: 'solid', + borderBlockStartColor: tokens.colorNeutralStroke2, + }, + }, + title: { + color: tokens.colorNeutralForeground1, + fontSize: tokens.fontSizeBase300, + fontWeight: tokens.fontWeightSemibold, + lineHeight: tokens.lineHeightBase300, + paddingInlineStart: tokens.spacingHorizontalS, + }, +}); + +/** Class names that style parts of a section. */ +export type AppLauncherSectionClasses = { + /** Applied to the section element. */ + root: string; + /** Added to the root when the section draws a divider. */ + rootWithDivider: string; + /** Applied to the section title. */ + title: string; +}; + +/** + * Resolves the section style classes. + * + * Section spacing and the divider line only apply from the second section + * on: the first section sits flush against the surface padding, and each + * following section is separated by space, plus a divider line when the + * section asks for one. + * + * @returns class names for the section slots + */ +export function useAppLauncherSectionStyles(): AppLauncherSectionClasses { + const styles = useStyles(); + + return { + root: styles.root, + rootWithDivider: styles.rootWithDivider, + title: styles.title, + }; +} diff --git a/packages/react-app-launcher/src/components/AppLauncherTrigger/AppLauncherTrigger.test.tsx b/packages/react-app-launcher/src/components/AppLauncherTrigger/AppLauncherTrigger.test.tsx new file mode 100644 index 000000000..f23ddf6ca --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherTrigger/AppLauncherTrigger.test.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { render, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { AppLauncherTrigger } from '../../index'; + +/** + * Renders the trigger inside the theme provider, mirroring how applications + * mount it. + */ +function renderTrigger(trigger: ReactElement) { + return render( + {trigger} + ); +} + +describe('AppLauncherTrigger', () => { + it('renders an icon-only button whose accessible name is the default label', () => { + renderTrigger(); + + const button = screen.getByRole('button', { name: 'App launcher' }); + + expect(button.textContent).toBe(''); + expect(button.querySelector('svg')).not.toBeNull(); + }); + + it('uses the provided label as the accessible name', () => { + renderTrigger(); + + expect(screen.getByRole('button', { name: 'Applaus' })).toBeDefined(); + }); +}); diff --git a/packages/react-app-launcher/src/components/AppLauncherTrigger/AppLauncherTrigger.tsx b/packages/react-app-launcher/src/components/AppLauncherTrigger/AppLauncherTrigger.tsx new file mode 100644 index 000000000..6e5abd73c --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherTrigger/AppLauncherTrigger.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import type { AppLauncherTriggerProps } from './AppLauncherTrigger.types'; +import { renderAppLauncherTrigger } from './renderAppLauncherTrigger'; +import { useAppLauncherTrigger } from './useAppLauncherTrigger'; + +/** + * Icon-only trigger button for the application launcher. + * + * Renders the Fluent waffle glyph in a subtle button and exposes `label` as + * the accessible name and the tooltip. The component forwards its ref to the + * button element and passes every other `Button` prop through, so a later + * iteration can wrap it in `PopoverTrigger` without changing this API. + * + * @example + * ```tsx + * + * ``` + */ +export const AppLauncherTrigger = React.forwardRef< + HTMLButtonElement, + AppLauncherTriggerProps +>((props, ref) => { + const state = useAppLauncherTrigger(props, ref); + + return renderAppLauncherTrigger(state); +}); + +AppLauncherTrigger.displayName = 'AppLauncherTrigger'; diff --git a/packages/react-app-launcher/src/components/AppLauncherTrigger/AppLauncherTrigger.types.ts b/packages/react-app-launcher/src/components/AppLauncherTrigger/AppLauncherTrigger.types.ts new file mode 100644 index 000000000..e0bcbfe55 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherTrigger/AppLauncherTrigger.types.ts @@ -0,0 +1,48 @@ +import type { ButtonProps } from '@fluentui/react-components'; +import type { ComponentPropsWithoutRef, ReactElement } from 'react'; + +/** + * Props of the app launcher trigger. + * + * The trigger renders an icon-only button, so `children` is not part of the + * API: the waffle glyph is the icon and `label` is both the accessible name + * and the tooltip text. All native button props (`onClick`, `className`, + * ARIA attributes, event handlers, ...) pass through to the element, which + * also lets a later `PopoverTrigger` inject its own props without an API + * change. + */ +export type AppLauncherTriggerProps = Omit< + ComponentPropsWithoutRef<'button'>, + 'children' +> & { + /** + * Accessible name of the trigger, also shown as the tooltip. + * + * Defaults to `'App launcher'`. Applications should pass a localized + * string so the button is named in the user's language. + */ + label?: string; + + /** + * Icon rendered inside the button. Defaults to the Fluent waffle glyph + * (`GridDots20Regular`). + */ + icon?: ReactElement; + + /** Visual style of the button. Defaults to `'subtle'`. */ + appearance?: ButtonProps['appearance']; + + /** Size of the button. */ + size?: ButtonProps['size']; + + /** Shape of the button. */ + shape?: ButtonProps['shape']; + + /** + * Suppresses the tooltip without removing it from the tree, so the + * button element keeps its identity. The launcher sets this while its + * surface is open so the label does not float over the panel. Defaults + * to `false`. + */ + hideTooltip?: boolean; +}; diff --git a/packages/react-app-launcher/src/components/AppLauncherTrigger/index.ts b/packages/react-app-launcher/src/components/AppLauncherTrigger/index.ts new file mode 100644 index 000000000..dedcb75b1 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherTrigger/index.ts @@ -0,0 +1,2 @@ +export { AppLauncherTrigger } from './AppLauncherTrigger'; +export type { AppLauncherTriggerProps } from './AppLauncherTrigger.types'; diff --git a/packages/react-app-launcher/src/components/AppLauncherTrigger/renderAppLauncherTrigger.tsx b/packages/react-app-launcher/src/components/AppLauncherTrigger/renderAppLauncherTrigger.tsx new file mode 100644 index 000000000..a2692df09 --- /dev/null +++ b/packages/react-app-launcher/src/components/AppLauncherTrigger/renderAppLauncherTrigger.tsx @@ -0,0 +1,50 @@ +/** @jsxRuntime classic */ +/** @jsx createElement */ + +import { Button, Tooltip } from '@fluentui/react-components'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { createElement } from '@fluentui/react-jsx-runtime'; +import type { ReactElement } from 'react'; +import type { AppLauncherTriggerState } from './useAppLauncherTrigger'; + +/** + * Renders the app launcher trigger. + * + * The tooltip is bound with `relationship="label"` so sighted users and + * assistive technology read the same text, and the explicit `aria-label` + * keeps the button named even while the tooltip is hidden. The caller can + * suppress the tooltip with `hideTooltip` — the launcher does so while its + * surface is open, so the label does not float over the panel. + * + * Suppressing keeps the tooltip mounted and only changes its `visible` prop: + * removing the wrapper would replace the button element in the DOM whenever + * the panel opens, losing DOM identity and any focus the button holds. The + * tooltip is therefore visibility-controlled — it reports hover and focus + * requests through `onVisibleChange`, and the render forces it hidden while + * `hideTooltip` is set. + * + * @param state - resolved trigger state + * @returns the icon-only trigger button inside its label tooltip + */ +export function renderAppLauncherTrigger( + state: AppLauncherTriggerState +): ReactElement { + return ( + + + + + + Toolbar trigger + + + The trigger prop accepts any element that forwards props and a ref — + the same contract Fluent's PopoverTrigger expects — so a ToolbarButton + can replace the built-in waffle and keep the toolbar's keyboard + behavior. Open the drawer to see the launcher as one command of the + drawer's toolbar. + + + + ); +}; diff --git a/packages/react-app-launcher/stories/AppLauncher/TriggerAnchoredList.stories.tsx b/packages/react-app-launcher/stories/AppLauncher/TriggerAnchoredList.stories.tsx new file mode 100644 index 000000000..d229a4817 --- /dev/null +++ b/packages/react-app-launcher/stories/AppLauncher/TriggerAnchoredList.stories.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { Text, makeStyles } from '@fluentui/react-components'; +import { AppLauncher } from '@fluentui-contrib/react-app-launcher'; +import { + AppBar, + AppRows, + Brand, + Captions, + LauncherTriggerButton, + Page, + apps, + useActivatingLauncher, +} from './apps'; + +const useStyles = makeStyles({ + end: { + marginInlineStart: 'auto', + }, +}); + +/** + * Trigger anchoring: the launcher sits at the end of the bar but does not + * dock to it. The panel anchors to the trigger and aligns with one of its + * edges — here the end edge, so the panel's right side lines up with the + * waffle. The content is a plain list of rows instead of a grid of tiles: + * the blocks compose freely. + */ +export const TriggerAnchoredList = () => { + const styles = useStyles(); + const launcher = useActivatingLauncher(); + + return ( + + + +
    + } + positioning={{ position: 'below', align: 'end' }} + open={launcher.open} + onOpenChange={launcher.onOpenChange} + > + + +
    +
    + + + Trigger-anchored list + + + The positioning prop selects the anchor edge: below-end opens the + panel under the trigger, right edges aligned. The panel floats over + the page — it is not docked to any window edge — and the content is a + list of full-width rows instead of tiles. Activating a row closes the + panel. + + +
    + ); +}; diff --git a/packages/react-app-launcher/stories/AppLauncher/apps.tsx b/packages/react-app-launcher/stories/AppLauncher/apps.tsx new file mode 100644 index 000000000..543f6a13f --- /dev/null +++ b/packages/react-app-launcher/stories/AppLauncher/apps.tsx @@ -0,0 +1,384 @@ +import { + CalendarClock20Regular, + CalendarClock24Regular, + CalendarLtr20Regular, + CalendarLtr32Regular, + Document20Regular, + Document32Regular, + Folder20Regular, + Folder32Regular, + Globe20Regular, + Globe32Regular, + GridDots20Regular, + Mail32Regular, + PeopleTeam20Regular, + PeopleTeam32Regular, + Settings20Regular, + Settings32Regular, + Video20Regular, + Video32Regular, +} from '@fluentui/react-icons'; +import { + AppLauncherGrid, + AppLauncherItem, + AppLauncherList, +} from '@fluentui-contrib/react-app-launcher'; +import type { AppLauncherProps } from '@fluentui-contrib/react-app-launcher'; +import { Button, Text, makeStyles, tokens } from '@fluentui/react-components'; +import type { ButtonProps } from '@fluentui/react-components'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { + ChatLogo, + FilesLogo, + InsightsLogo, + MailLogo, + NotesLogo, + WhiteboardLogo, +} from './logos'; + +/** + * One app of the sample sets: the label the item shows, the icon sized for + * the layout it appears in, and the disabled flavors. + */ +export type AppEntry = { + label: string; + /** Icon sized for the grid tiles — a Fluent `*32` icon or a logo. */ + icon: ReactElement; + /** + * Optional smaller icon sized for the compact list rows (`*20` Fluent + * icons, or a logo at `size={20}`). The stress sample omits it and falls + * back to `icon`. + */ + smallIcon?: ReactElement; + /** + * When true the item renders disabled: muted, unfocusable and never + * activating. + */ + disabled?: boolean; + /** + * When true the item renders `disabledFocusable`: announced disabled and + * never activating, but still reachable with the keyboard. + */ + disabledFocusable?: boolean; +}; + +/** + * Apps of the stories, sized like the Microsoft 365 launcher tiles. + * + * The icons deliberately mix the two treatments an application can pass: a + * monochrome Fluent icon, or a colorful logo element. `Bookings` and + * `Insights` render disabled, so the disabled look sits next to the enabled + * tiles; `Bookings (focusable)` renders `disabledFocusable` beside `Bookings` + * so the two disabled flavors can be compared, and `Contoso Resource + * Planning` carries a deliberately long name to compare wrapping (tiles) + * with ellipsizing (rows). + */ +export const apps: AppEntry[] = [ + { label: 'Mail', icon: , smallIcon: }, + { + label: 'Calendar', + icon: , + smallIcon: , + }, + { label: 'Files', icon: , smallIcon: }, + { + label: 'Teams', + icon: , + smallIcon: , + }, + { label: 'Chat', icon: , smallIcon: }, + { + label: 'Documents', + icon: , + smallIcon: , + }, + { label: 'Calls', icon: , smallIcon: }, + { label: 'Notes', icon: , smallIcon: }, + { label: 'Sites', icon: , smallIcon: }, + // The CalendarClock family has no 32px size at the icon version this + // repository supports (2.0.260), so the tile uses the 24px glyph. + { + label: 'Bookings', + icon: , + smallIcon: , + disabled: true, + }, + { + label: 'Bookings (focusable)', + icon: , + smallIcon: , + disabledFocusable: true, + }, + { + label: 'Whiteboard', + icon: , + smallIcon: , + }, + { + label: 'Contoso Resource Planning', + icon: , + smallIcon: , + }, + { + label: 'Settings', + icon: , + smallIcon: , + }, + { + label: 'Insights', + icon: , + smallIcon: , + disabled: true, + }, +]; + +/** Apps pinned to the top of the kitchen-sink story. */ +export const pinnedApps = apps.slice(0, 3); + +/** The remaining apps, shown under the "All apps" title. */ +export const allApps = apps.slice(3); + +/** The apps the kitchen-sink story treats as recently used on first open. */ +export const initialRecentLabels = ['Mail', 'Calendar', 'Chat']; + +/** + * Icon cycle for the full-dock story. Stored as components rather than JSX + * elements so the list is not mistaken for renderable content. + */ +const stressIcons = [ + Mail32Regular, + CalendarLtr32Regular, + Globe32Regular, + PeopleTeam32Regular, + Video32Regular, + Document32Regular, +]; + +/** Enough tiles to make the full docking story scroll. */ +export const stressApps: AppEntry[] = Array.from({ length: 40 }, (_, index) => { + const Icon = stressIcons[index % stressIcons.length] ?? Mail32Regular; + + return { + label: `App ${index + 1}`, + icon: , + // Every tenth tile stands in for an app disabled by business rules. + disabled: (index + 1) % 10 === 0, + }; +}); + +/** + * Keeps the open state of a story launcher so activating an app closes the + * panel, the way a launcher behaves. The package leaves that behavior to the + * application: it renders whatever the caller composes and cannot know that + * an item was activated. + * + * @param onOpen - called right before the panel opens, for state that should + * be applied at open time (the recent order in the kitchen-sink story) + * @returns the open state, the change handler and a close helper for clicks + */ +export function useActivatingLauncher(onOpen?: () => void) { + const [open, setOpen] = React.useState(false); + + const onOpenChange: NonNullable = ( + _event, + data + ) => { + if (data.open) { + onOpen?.(); + } + setOpen(data.open); + }; + + return { open, onOpenChange, close: () => setOpen(false) }; +} + +/** + * Lays a list of apps out as launcher tiles. + * + * @param props - component props + * @param props.items - apps to render + * @param props.onActivate - called with the app when its tile is activated + */ +export function AppTiles({ + items, + onActivate, +}: { + items: AppEntry[]; + onActivate?: (item: AppEntry) => void; +}) { + return ( + + {items.map((item) => ( + onActivate(item) : undefined} + /> + ))} + + ); +} + +/** + * Lays a list of apps out as launcher rows. + * + * @param props - component props + * @param props.items - apps to render + * @param props.onActivate - called with the app when its row is activated + */ +export function AppRows({ + items, + onActivate, +}: { + items: AppEntry[]; + onActivate?: (item: AppEntry) => void; +}) { + return ( + + {items.map((item) => ( + onActivate(item) : undefined} + /> + ))} + + ); +} + +const useFrameStyles = makeStyles({ + page: { + display: 'flex', + flexDirection: 'column', + minHeight: '100vh', + backgroundColor: tokens.colorNeutralBackground2, + }, + bar: { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '6px 16px', + backgroundColor: tokens.colorBrandBackground, + }, + brand: { + color: tokens.colorNeutralForegroundOnBrand, + }, + // The trigger sits on the brand bar, so it must keep its on-brand colors + // in every interaction state: Fluent's transparent Button repaints its + // foreground with brand-hover colors on hover and press, which vanish on a + // brand-colored bar. The `&.fui-Button` selectors outrank the Button's own + // `:hover` rules, and the icon pins its own color through `waffle` so it + // cannot be repainted by inheritance. + trigger: { + color: tokens.colorNeutralForegroundOnBrand, + '&.fui-Button:hover': { + color: tokens.colorNeutralForegroundOnBrand, + backgroundColor: 'rgba(255, 255, 255, 0.12)', + }, + '&.fui-Button:hover:active': { + color: tokens.colorNeutralForegroundOnBrand, + backgroundColor: 'rgba(255, 255, 255, 0.22)', + }, + }, + waffle: { + color: tokens.colorNeutralForegroundOnBrand, + }, + card: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + maxWidth: '560px', + margin: '24px', + padding: '16px', + borderRadius: tokens.borderRadiusXLarge, + backgroundColor: tokens.colorNeutralBackground1, + }, +}); + +/** + * A minimal application bar in the Contoso brand blue: the context the + * launcher docks to in the stories. The forwarded ref points at the bar + * element, so a story can pass it as the launcher's `dockTarget`. + */ +export const AppBar = React.forwardRef( + function AppBar({ children }, ref) { + const styles = useFrameStyles(); + + return ( +
    + {children} +
    + ); + } +); + +/** + * The application name shown in the bar, in the on-brand foreground color. + */ +export function Brand({ name = 'Contoso' }: { name?: string }) { + const styles = useFrameStyles(); + + return ( + + {name} + + ); +} + +/** + * The waffle trigger styled for the brand-colored bar: a transparent Button + * whose icon keeps the on-brand color in every state, with a soft white + * overlay as the hover and press feedback. + * + * Forwards its props and ref to the Button, which is the contract the + * launcher requires of a caller-provided trigger: `PopoverTrigger` injects + * event handlers and its anchor ref into the element it renders. + * + * @param props - component props plus anything the launcher injects + * @param props.label - accessible name of the trigger + * @param ref - ref to the root button element, forwarded to the Button + */ +export const LauncherTriggerButton = React.forwardRef< + HTMLButtonElement, + { label?: string } & Omit +>(function LauncherTriggerButton({ label = 'App launcher', ...rest }, ref) { + const styles = useFrameStyles(); + + return ( +