diff --git a/.gitignore b/.gitignore index e3009e6..18e828f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ node_modules/ .pnp.loader.mjs .yarnrc.yml *storybook.log +.env +drafts/ diff --git a/AGENTS.md b/AGENTS.md index 33ec614..cab7d21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,12 +11,6 @@ respond in that language. - If a message is mixed-language, reply in the dominant language unless the user specifies otherwise. -- Run `make eslint` before handoff or commit preparation only when changed - files include code covered by eslint rules (for example `*.js`, `*.ts`, - and similar source files). Do not run `make eslint` for markdown-only - changes (for example `*.md`). -- Getter/helper functions must be side-effect free. Side effects are allowed - only by prior agreement and only when there are strong, explicit reasons. ## Reporting - Keep handoff reports natural and outcome-focused: describe what was done. @@ -26,9 +20,9 @@ affect correctness, safety, or reproducibility. ## Purpose -This file defines practical instructions for working in the -`modulify/m3-web` repository, with a focus on test execution and commit -workflow. +This file defines practical instructions for day-to-day work in the +`modulify/m3-web` repository, with a focus on repository conventions, +test execution, and standard local commands. ## Repository Structure - This project is a Yarn Workspaces monorepo. @@ -38,13 +32,27 @@ workflow. - Vitest workspace targets are declared in `vitest.workspace.ts`: `m3-react`, `m3-vue`. -## Local Environment Prerequisites +## Architecture Rules +- Getter/helper functions must be side-effect free. Side effects are allowed + only by prior agreement and only when there are strong, explicit reasons. +- Tests inside a workspace must cover only code owned by that workspace. In + `m3-foundation`, `m3-react`, `m3-vue`, and any future workspace, do not add + tests for implementation that belongs to another workspace. +- Cross-workspace imports must go through the target workspace package name as + declared in `package.json`. Relative or absolute filesystem imports into + another workspace are forbidden. For example, + `import type { Appearance } from '@modulify/m3-foundation/types/components/button'` + is allowed, but + `import type { Appearance } from '../../../m3-foundation/types/components/button'` + is not. + +## Local setup - Yarn version is `4.6.0` (see `packageManager` in `package.json`). -- Local `.yarnrc.yml` is generated from `.yarnrc.yml.dist` using: +- Generate local `.yarnrc.yml` from `.yarnrc.yml.dist`: ```bash make .yarnrc.yml ``` -- Install dependencies with: +- Install dependencies: ```bash make node_modules # or @@ -54,16 +62,6 @@ yarn install ## Running Tests ### Local Path -- Generate local Yarn config: -```bash -make .yarnrc.yml -``` -- Install dependencies: -```bash -make node_modules -# or -yarn install -``` - Run all tests: ```bash make test @@ -104,44 +102,47 @@ make help ``` ## Important Project Rules -- Commit messages follow Conventional Commits. -- Commitlint configuration is in `.commitlintrc.json` with: - `header-max-length=200`, `body-max-line-length=200`, - `footer-max-line-length=200`, `subject-case=never`. -- Getter/helper functions must be side-effect free. Side effects are allowed - only by prior agreement and only when there are strong, explicit reasons. +- Before performing actions, analyze whether there is a suitable local skill + for the task and consult it for detailed instructions. +- Before performing repeated or operational actions, inspect `make help` and + its output to see whether an existing recipe already covers the task. +- If a suitable recipe exists, prefer it over ad hoc commands to reduce extra + work, keep workflows standardized, and avoid unnecessary escalations. +- The project includes a Playwright container and make recipes for screenshot + capture; use them when visual analysis of Storybook pages, component states, + or other UI behavior is helpful. +- The project also includes runtime-analysis research recipes for DOM, styles, + layout metrics, a11y snapshots, traces, network/performance logs, token + diffs, and screenshot matrices; use them to reduce uncertainty and to + understand what is going wrong before guessing at visual or runtime issues. + Read `docs/en/runtime-analysis-recipes.md` first when the task involves + visual regressions, layout ambiguity, token/theme uncertainty, unclear + animation behavior, or other runtime issues where these recipes may help. +- Run eslint before handoff or commit preparation only when changed files + include code covered by eslint rules (for example `*.js`, `*.ts`, and + similar source files). Do not run eslint for markdown-only changes. +- Prefer running eslint with `--fix` when available so autofixable issues are + resolved automatically before manual follow-up. + +## Skills +The skills listed below are stored locally in this repository under `skills/`. + +If the context was compacted and you see `Context compacted`, reread any skill +whose description below starts with `[reread]` after the colon before +continuing. -## Commit Workflow -- Default commit message language is English (unless explicitly requested - otherwise). -- Commit style is Conventional Commits. -- Write commit subjects as historical facts (not intentions). -- Start commit subject description with an uppercase letter. -- Keep commit subject description concise. -- Move long details to commit body; lists in body are allowed for enumerations. -- Use past/perfective wording; prefer passive voice for changelog-friendly phrasing. -Examples: `Added ...`, `Removed ...`, `Refactored ...`, `Fixed ...`. -- Respect commitlint limits from `.commitlintrc.json`: - `header-max-length=200`, `body-max-line-length=200`, - `footer-max-line-length=200`. -- For workspace commits, use scope equal to the workspace directory name: - `m3-foundation`, `m3-react`, `m3-vue`. -- Split commits by logical change. Workspace-local changes should stay in - their workspace scope. -- Changes in `yarn.lock` must always be committed separately from all other files. -- Commit message for `yarn.lock`-only commit must be exactly: -`chore: Updated yarn.lock`. -- Exception for intentional dependency updates: -if commit purpose is dependency update (`yarn up`, `yarn add`, `yarn remove`, etc.), -after rebase conflict resolution rerun the original dependency command and recreate separate -`chore: Updated yarn.lock` commit. -- Exception: global workspace-level changes can be combined in one commit. -Global examples: eslint rule updates, shared dependency updates, repository-level infra/config changes. -- For commit tasks, use the local skill: -`skills/commit-workflow/SKILL.md`. -- For `yarn.lock` merge/rebase conflict resolution, use the local skill: -`skills/yarn-lock-conflict-resolution/SKILL.md`. -- For coverage deficit analysis and recovery strategy, use the local skill: -`skills/coverage-recovery/SKILL.md`. -- For documentation creation or edits under `docs/` with locale parity, use the local skill: -`skills/docs-parity/SKILL.md`. +- `commit-workflow`: [reread] Use when creating or splitting git commits in + this repository. Reread it before every commit creation; it standardizes + commit grouping, Conventional Commits, workspace scopes, and commitlint + limits. +- `coverage-recovery`: Use when coverage is below target or uncovered code must + be analyzed and closed without adding artificial tests. +- `docs-parity`: Use when creating or editing files under `docs/`. Keeps + English-first edits, locale parity, and locale index updates aligned. +- `exploration-workflow`: [reread] Use only when the user explicitly switches + the task into exploration mode: autonomous hypothesis-driven work on + uncertain functionality, with timeboxing, milestone logging, and tightly + controlled pre-agreed escalation windows, plus `drafts/current.yml` and a + dedicated `drafts/` activity directory for logs, facts, and artifact links. +- `yarn-lock-conflict-resolution`: Use when resolving merge or rebase conflicts + in `yarn.lock` according to repository policy. diff --git a/Makefile b/Makefile index 824cfbf..1276beb 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,18 @@ +.DEFAULT_GOAL := help + TARGET_HEADER=@echo -e '===== \e[34m' $@ '\e[0m' TARGET_OK=@echo -e '\e[32mOK\e[0m' YARN=@docker-compose run --rm node yarn -YARN_PLAYWRIGHT=@docker-compose run --rm playwright yarn -COVERAGE_PARTS_DIR=coverage/.parts -COVERAGE_UNIT_DIR=coverage/unit -COVERAGE_E2E_REACT_DIR=coverage/e2e-react -COVERAGE_E2E_VUE_DIR=coverage/e2e-vue -NYC_OUTPUT_DIR=.nyc_output +PLAYWRIGHT_NODE_CMD=docker-compose run --rm playwright node +M3_UA?=m3-web-research/1.0 .PHONY: up -up: ## Starts storybook +up: ## [Dev][docker] Starts storybook $(TARGET_HEADER) docker-compose up -d .PHONY: restart -restart: ## Restarts all docker services or a particular service, if argument "service" is specified (example: make restart service="storybook"). +restart: ## [Dev][docker] Restarts all docker services or a particular service, if argument "service" is specified (example: make restart service="storybook") $(TARGET_HEADER) ifdef service @@ -24,213 +22,27 @@ else endif .PHONY: stop -stop: ## Stops all docker services +stop: ## [Dev][docker] Stops all docker services $(TARGET_HEADER) docker-compose stop -.PHONY: .yarnrc.yml -.yarnrc.yml: ## Creates yarn configuration - @cp .yarnrc.yml.dist .yarnrc.yml - -.PHONY: node_modules -node_modules: package.json yarn.lock ## Installs dependencies - $(TARGET_HEADER) - $(YARN) install - @touch node_modules || true - @echo "" +include recipes/common.mk .PHONY: build -build: node_modules ## Creates a dist catalogue with library build +build: node_modules ## [Build][docker] Creates a dist catalogue with library build $(TARGET_HEADER) $(YARN) build -.PHONY: storybook-build-test -storybook-build-test: node_modules ## Builds Storybook in --test mode for all UI workspaces - $(TARGET_HEADER) - $(YARN) workspace @modulify/m3-react storybook:build --test --quiet - $(YARN) workspace @modulify/m3-vue storybook:build --test --quiet - -.PHONY: storybook-build-test-react -storybook-build-test-react: node_modules ## Builds Storybook in --test mode for @modulify/m3-react - $(TARGET_HEADER) - $(YARN) workspace @modulify/m3-react storybook:build --test --quiet - -.PHONY: storybook-build-test-vue -storybook-build-test-vue: node_modules ## Builds Storybook in --test mode for @modulify/m3-vue - $(TARGET_HEADER) - $(YARN) workspace @modulify/m3-vue storybook:build --test --quiet - -.PHONY: test-smoke -test-smoke: node_modules ## Runs smoke tests for all UI workspaces - $(TARGET_HEADER) - $(YARN) test:smoke - -.PHONY: test-runtime-parity -test-runtime-parity: ## Checks Node/Yarn parity across docker services (node, storybook, playwright) - $(TARGET_HEADER) - ./runtime-parity.test.sh - -.PHONY: husky -husky: node_modules ## Adds husky git hooks with commit content checks - @docker-compose run --rm node npx husky init - -.PHONY: eslint -eslint: node_modules ## Runs eslint - $(TARGET_HEADER) - $(YARN) eslint - -.PHONY: tsc -tsc: node_modules ## Runs type checks in all workspaces - $(TARGET_HEADER) - $(YARN) tsc - -.PHONY: tsc-foundation -tsc-foundation: node_modules ## Runs type checks in @modulify/m3-foundation - $(TARGET_HEADER) - $(YARN) workspace @modulify/m3-foundation tsc - -.PHONY: tsc-react -tsc-react: node_modules ## Runs type checks in @modulify/m3-react - $(TARGET_HEADER) - $(YARN) workspace @modulify/m3-react tsc - -.PHONY: tsc-vue -tsc-vue: node_modules ## Runs type checks in @modulify/m3-vue - $(TARGET_HEADER) - $(YARN) workspace @modulify/m3-vue tsc - -.PHONY: tsc-tests -tsc-tests: node_modules ## Runs type checks for tests in all UI workspaces - $(TARGET_HEADER) - $(YARN) tsc:tests - -.PHONY: tsc-tests-react -tsc-tests-react: node_modules ## Runs type checks for tests in @modulify/m3-react - $(TARGET_HEADER) - $(YARN) workspace @modulify/m3-react tsc:tests - -.PHONY: tsc-tests-vue -tsc-tests-vue: node_modules ## Runs type checks for tests in @modulify/m3-vue - $(TARGET_HEADER) - $(YARN) workspace @modulify/m3-vue tsc:tests - -.PHONY: tsc-e2e -tsc-e2e: node_modules ## Runs type checks for Playwright Vitest configs - $(TARGET_HEADER) - $(YARN) exec tsc -p tsconfig.e2e.json --skipLibCheck - -.PHONY: test -test: node_modules ## Runs autotests - $(TARGET_HEADER) - -ifdef cli - @echo "${YARN} test ${cli}" - $(YARN) test $(cli) -else - @echo "${YARN} test" - $(YARN) test -endif - -.PHONY: test-coverage -test-coverage: node_modules ## Runs merged coverage for unit and Playwright e2e tests - $(TARGET_HEADER) - @rm -rf coverage $(NYC_OUTPUT_DIR) artifacts - @mkdir -p $(COVERAGE_PARTS_DIR) $(NYC_OUTPUT_DIR) - $(YARN) test --coverage --coverage.provider=istanbul --coverage.reporter=json --coverage.reportsDirectory=$(COVERAGE_UNIT_DIR) - $(YARN_PLAYWRIGHT) test:e2e:coverage - @cp $(COVERAGE_UNIT_DIR)/coverage-final.json $(COVERAGE_PARTS_DIR)/unit.json - @cp $(COVERAGE_E2E_REACT_DIR)/coverage-final.json $(COVERAGE_PARTS_DIR)/e2e-react.json - @cp $(COVERAGE_E2E_VUE_DIR)/coverage-final.json $(COVERAGE_PARTS_DIR)/e2e-vue.json - @$(YARN) nyc merge $(COVERAGE_PARTS_DIR) $(NYC_OUTPUT_DIR)/coverage-final.json >/dev/null - $(YARN) nyc report - @$(YARN) nyc report --reporter=json-summary >/dev/null - @$(YARN) node --experimental-strip-types scripts/show-total-coverage.ts - -.PHONY: test-e2e -test-e2e: node_modules ## Runs Playwright-based e2e tests (Vitest browser mode) - $(TARGET_HEADER) - -ifdef cli - @$(YARN_PLAYWRIGHT) test:e2e $(cli) -else - @$(YARN_PLAYWRIGHT) test:e2e -endif - -.PHONY: test-e2e-react -test-e2e-react: node_modules ## Runs Playwright-based e2e tests for @modulify/m3-react - $(TARGET_HEADER) - -ifdef cli - @$(YARN_PLAYWRIGHT) workspace @modulify/m3-react test:e2e $(cli) -else - @$(YARN_PLAYWRIGHT) workspace @modulify/m3-react test:e2e -endif - -.PHONY: test-e2e-vue -test-e2e-vue: node_modules ## Runs Playwright-based e2e tests for @modulify/m3-vue - $(TARGET_HEADER) - -ifdef cli - @$(YARN_PLAYWRIGHT) workspace @modulify/m3-vue test:e2e $(cli) -else - @$(YARN_PLAYWRIGHT) workspace @modulify/m3-vue test:e2e -endif - -.PHONY: test-e2e-stop -test-e2e-stop: ## Stops stuck Playwright E2E host processes and run containers - $(TARGET_HEADER) - @pkill -TERM -f "docker-compose run --rm playwright yarn test:[e]2e" || true - @pkill -TERM -f "docker-compose run --rm playwright yarn workspace @modulify/m3-react test:[e]2e" || true - @pkill -TERM -f "docker-compose run --rm playwright yarn workspace @modulify/m3-vue test:[e]2e" || true - @docker ps -q --filter "name=m3-web-playwright-run" | xargs -r docker rm -f - -.PHONY: ci-actionlint -ci-actionlint: ## Lints GitHub Actions workflows locally (actionlint binary or docker image) - $(TARGET_HEADER) - @if command -v actionlint >/dev/null 2>&1; then \ - actionlint; \ - elif command -v docker >/dev/null 2>&1; then \ - docker run --rm -v "$$(pwd):/repo" -w /repo rhysd/actionlint:latest; \ - else \ - echo "actionlint is not installed and docker is unavailable"; \ - exit 1; \ - fi - $(TARGET_OK) - -.PHONY: ci-act-plan -ci-act-plan: ## Shows act execution plan for tests workflow without running jobs - $(TARGET_HEADER) - @if command -v act >/dev/null 2>&1; then \ - act -P ubuntu-latest=catthehacker/ubuntu:act-latest -n pull_request -W .github/workflows/tests.yml; \ - elif command -v docker >/dev/null 2>&1; then \ - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "$$(pwd):/repo" -w /repo alpine:3.20 sh -lc "apk add --no-cache curl tar >/dev/null && curl -fsSL https://github.com/nektos/act/releases/download/v0.2.84/act_Linux_x86_64.tar.gz | tar -xz -C /tmp && /tmp/act -P ubuntu-latest=catthehacker/ubuntu:act-latest -n pull_request -W .github/workflows/tests.yml"; \ - else \ - echo "act is not installed and docker is unavailable"; \ - exit 1; \ - fi - $(TARGET_OK) - -.PHONY: ci-act-tests -ci-act-tests: ## Runs tests workflow locally via act (pr-check, eslint, tests, storybook-tests) - $(TARGET_HEADER) - @if command -v act >/dev/null 2>&1; then \ - act -P ubuntu-latest=catthehacker/ubuntu:act-latest pull_request -W .github/workflows/tests.yml -j pr-check -j eslint -j tests -j storybook-tests; \ - elif command -v docker >/dev/null 2>&1; then \ - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "$$(pwd):/repo" -w /repo alpine:3.20 sh -lc "apk add --no-cache curl tar >/dev/null && curl -fsSL https://github.com/nektos/act/releases/download/v0.2.84/act_Linux_x86_64.tar.gz | tar -xz -C /tmp && /tmp/act -P ubuntu-latest=catthehacker/ubuntu:act-latest pull_request -W .github/workflows/tests.yml -j pr-check -j eslint -j tests -j storybook-tests"; \ - else \ - echo "act is not installed and docker is unavailable"; \ - exit 1; \ - fi - $(TARGET_OK) - -.PHONY: ci-check -ci-check: ci-actionlint ci-act-plan ## Validates CI workflow config and prints local act plan - $(TARGET_OK) +include recipes/lint.mk +include recipes/tests.mk +include recipes/storybook.mk +include recipes/ci.mk +include recipes/research.mk +include recipes/exploration.mk .PHONY: help -help: ## Calls recipes list - @cat $(MAKEFILE_LIST) | grep -e "^[-a-zA-Z_\.]*: *.*## *" | awk '\ - BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' +help: ## [General] Shows grouped command help + @HELP_FILTER="$(value filter)" HELP_SHOW_INTERNAL="$(value show_internal)" ./recipes/help.sh $(MAKEFILE_LIST) # Colors $(call computable,CC_BLACK,$(shell tput -Txterm setaf 0 2>/dev/null)) diff --git a/docs/en/index.md b/docs/en/index.md index 3d3428c..012396c 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -3,6 +3,7 @@ ## Contents - [Contributing Guide](./contributing.md) - Development workflow, core commands, and CI quality gates. +- [Runtime Analysis Recipes](./runtime-analysis-recipes.md) - Playwright-based recipes for visual, layout, style, motion, and runtime investigation. - [TSConfig Layering](./tsconfig-layering.md) - How TypeScript configs are split between editor DX and CI checks. ## Translations diff --git a/docs/en/runtime-analysis-recipes.md b/docs/en/runtime-analysis-recipes.md new file mode 100644 index 0000000..7bed121 --- /dev/null +++ b/docs/en/runtime-analysis-recipes.md @@ -0,0 +1,103 @@ +# Runtime Analysis Recipes + +This document describes the Playwright-based research recipes available in +`m3-web` for reducing uncertainty when visual or runtime behavior is unclear. + +## When To Use + +Use these recipes when: +- a Storybook page looks wrong and screenshots are not enough; +- a layout shifts, overlaps, or animates unexpectedly; +- local theming or CSS tokens appear to be ignored; +- React and Vue parity is unclear; +- visual regressions need before/after or matrix comparison; +- runtime errors may be hidden in console, network, or accessibility state. + +## Core Principle + +Prefer these recipes before guessing. They are intended to turn “something is +off” into concrete evidence: screenshots, computed styles, layout metrics, +accessibility trees, traces, and diffs. + +## Most Useful Recipes + +- `make research-capture url='...'` - Single screenshot with metadata. +- `make research-capture-batch in=urls.txt out_dir=drafts/screenshots/...` - + Batch screenshot capture from a URL list. +- `make research-style-dump url='...' selector='...'` - Computed styles and CSS + custom properties for one element. +- `make research-layout-metrics url='...' selectors='.a||.b||.c'` - Bounding + boxes, scroll metrics, offsets, and layout details for multiple elements. +- `make research-token-diff url='...' selector='.left' compare_selector='.right'` + - CSS variable diff between two scopes. +- `make research-console-capture url='...'` - Console messages, page errors, + and failed requests. +- `make research-a11y-snapshot url='...' selector='...'` - Accessibility tree + for the page or a subtree. +- `make research-trace url='...' action_selector='...' interaction=click` - + Playwright trace for a page and optional interaction. +- `make research-motion-sample url='...' action_selector='...' interaction=click` + - Timed frame sequence for motion and transitions. +- `make research-capture-diff left=before.png right=after.png out=diff.png` - + Pixel diff for two images or directories. +- `make research-capture-matrix ...` - Capture one URL across multiple themes, + globals, args, and viewports. +- `make research-story-props story_id='...' themes='light,dark' args_sets='...'` + - Storybook-oriented matrix capture for story args and globals. + +## Typical Workflows + +## Investigate A Broken Layout + +1. Capture the current page: + `make research-capture url='...'` +2. Dump layout metrics: + `make research-layout-metrics url='...' selectors='.host||.panel||.scrim'` +3. Dump computed styles for the suspicious node: + `make research-style-dump url='...' selector='.panel'` + +## Investigate A Theme Or Token Problem + +1. Capture the page in both themes: + `make research-capture-matrix story_id='...' themes='light,dark'` +2. Compare token scopes: + `make research-token-diff url='...' selector='.default-scope' compare_selector='.local-scope'` +3. Inspect computed variables: + `make research-style-dump url='...' selector='.local-scope' var_prefixes='--m3-sys-,--m3-state-layers-'` + +## Investigate Animation Or Motion + +1. Capture a motion sequence: + `make research-motion-sample url='...' action_selector='...' interaction=click` +2. If behavior is still unclear, capture a trace: + `make research-trace url='...' action_selector='...' interaction=click` + +## Compare Two Runtime States + +1. Capture both states into separate directories. +2. Run: + `make research-capture-diff left=dir-a right=dir-b out_dir=drafts/research/diff` + +## Storybook Notes + +- Storybook pages can be opened directly with: + `http://m3-vue.modulify.test/?path=/story/...&globals=theme:dark` +- For systematic capture across states, prefer `research-story-props` and + `research-capture-matrix` over manually building many URLs. + +## Output Conventions + +- Screenshots are written under `drafts/screenshots/...` unless overridden. +- Runtime inspection outputs default to `drafts/research/...`. +- Most recipes emit machine-readable JSON so results can be inspected or + compared later. + +## Recommendation + +When a bug report is vague, start with: +1. `research-capture` +2. `research-style-dump` +3. `research-layout-metrics` + +That combination usually clarifies whether the problem is geometry, styling, +token inheritance, or runtime state. diff --git a/docs/ru/index.md b/docs/ru/index.md index 6a36c7c..e5f7526 100644 --- a/docs/ru/index.md +++ b/docs/ru/index.md @@ -3,4 +3,5 @@ ## Содержание - [Руководство по участию](./contributing.md) - Процесс разработки, ключевые команды и проверки в CI. +- [Рецепты runtime-анализа](./runtime-analysis-recipes.md) - Playwright-рецепты для исследования visual, layout, style, motion и runtime-проблем. - [Слои TSConfig](./tsconfig-layering.md) - Разделение TypeScript-конфигов для IDE и package-level typecheck. diff --git a/docs/ru/runtime-analysis-recipes.md b/docs/ru/runtime-analysis-recipes.md new file mode 100644 index 0000000..32cd720 --- /dev/null +++ b/docs/ru/runtime-analysis-recipes.md @@ -0,0 +1,105 @@ +# Рецепты runtime-анализа + +Этот документ описывает Playwright-рецепты исследования, доступные в +`m3-web`, чтобы снимать неопределённость, когда визуальное или runtime-поведение +непонятно. + +## Когда применять + +Используйте эти рецепты, когда: +- Storybook-страница выглядит неправильно, и одного скриншота недостаточно; +- layout сдвигается, наползает или анимируется неожиданно; +- локальная тема или CSS-токены как будто игнорируются; +- неясно, есть ли parity между React и Vue; +- нужно сравнить состояния до/после или матрицу сценариев; +- runtime-ошибки могут прятаться в console, network или accessibility-состоянии. + +## Базовый принцип + +Предпочитайте эти рецепты угадыванию. Их задача — превращать расплывчатое +«что-то не так» в конкретные артефакты: скриншоты, computed styles, layout +метрики, accessibility tree, traces и diffs. + +## Самые полезные рецепты + +- `make research-capture url='...'` - Один скриншот с метаданными. +- `make research-capture-batch in=urls.txt out_dir=drafts/screenshots/...` - + Пакетное снятие скриншотов по списку URL. +- `make research-style-dump url='...' selector='...'` - Computed styles и CSS + custom properties для одного элемента. +- `make research-layout-metrics url='...' selectors='.a||.b||.c'` - Bounding + boxes, scroll-метрики, offsets и layout-детали для нескольких элементов. +- `make research-token-diff url='...' selector='.left' compare_selector='.right'` + - Diff CSS-переменных между двумя scope. +- `make research-console-capture url='...'` - Console messages, page errors и + failed requests. +- `make research-a11y-snapshot url='...' selector='...'` - Accessibility tree + для страницы или subtree. +- `make research-trace url='...' action_selector='...' interaction=click` - + Playwright trace для страницы и опционального взаимодействия. +- `make research-motion-sample url='...' action_selector='...' interaction=click` + - Последовательность кадров для motion и transitions. +- `make research-capture-diff left=before.png right=after.png out=diff.png` - + Pixel diff для двух изображений или каталогов. +- `make research-capture-matrix ...` - Съёмка одного URL по нескольким themes, + globals, args и viewports. +- `make research-story-props story_id='...' themes='light,dark' args_sets='...'` + - Storybook-oriented matrix capture для story args и globals. + +## Типовые сценарии + +## Разобрать сломанный layout + +1. Снять текущую страницу: + `make research-capture url='...'` +2. Снять layout metrics: + `make research-layout-metrics url='...' selectors='.host||.panel||.scrim'` +3. Снять computed styles для подозрительного узла: + `make research-style-dump url='...' selector='.panel'` + +## Разобрать проблему темы или токенов + +1. Снять страницу в обеих темах: + `make research-capture-matrix story_id='...' themes='light,dark'` +2. Сравнить token scope: + `make research-token-diff url='...' selector='.default-scope' compare_selector='.local-scope'` +3. Посмотреть computed variables: + `make research-style-dump url='...' selector='.local-scope' var_prefixes='--m3-sys-,--m3-state-layers-'` + +## Разобрать анимацию или motion + +1. Снять последовательность кадров: + `make research-motion-sample url='...' action_selector='...' interaction=click` +2. Если поведение всё ещё неясно, снять trace: + `make research-trace url='...' action_selector='...' interaction=click` + +## Сравнить два runtime-состояния + +1. Снять оба состояния в разные каталоги. +2. Запустить: + `make research-capture-diff left=dir-a right=dir-b out_dir=drafts/research/diff` + +## Примечания по Storybook + +- Storybook-страницы можно открывать напрямую так: + `http://m3-vue.modulify.test/?path=/story/...&globals=theme:dark` +- Для систематической съёмки по состояниям лучше использовать + `research-story-props` и `research-capture-matrix`, а не собирать множество + URL вручную. + +## Формат артефактов + +- Скриншоты по умолчанию пишутся в `drafts/screenshots/...`. +- Runtime inspection outputs по умолчанию пишутся в `drafts/research/...`. +- Большинство рецептов сохраняет machine-readable JSON, чтобы результаты можно + было потом изучать и сравнивать. + +## Рекомендация + +Когда баг-репорт расплывчатый, начинайте с: +1. `research-capture` +2. `research-style-dump` +3. `research-layout-metrics` + +Эта комбинация обычно быстро показывает, проблема в геометрии, styling, +наследовании токенов или runtime-состоянии. diff --git a/evidence/popper.md b/evidence/popper.md new file mode 100644 index 0000000..b0468ac --- /dev/null +++ b/evidence/popper.md @@ -0,0 +1,70 @@ +# Popper Animation Evidence +Date: 2026-02-22 + +## Sources +### Primary references +- User-provided design reference video: + - https://firebasestorage.googleapis.com/v0/b/design-spec/o/projects%2Fgoogle-material-3%2Fimages%2Fmhlk1jwz-GM3_Menus_Guidelines%2001_IA_v01.mp4?alt=media&token=2e7dfcc9-2447-4808-8234-83c313d82df8 +- Material 3 motion pages: + - https://m3.material.io/styles/motion/overview + - https://m3.material.io/foundations/motion/applying-easing-and-duration + - https://m3.material.io/styles/motion/easing-and-duration/tokens-specs + +### Token/easing references used as practical source of truth +- Flutter generated motion tokens: + - https://flutter.googlesource.com/mirrors/packages/+/refs/tags/google_maps_flutter-v2.7.0/packages/flutter/lib/src/material/motion.dart +- Flutter API docs for durations and easing: + - https://api.flutter.dev/flutter/material/Durations-class.html + - https://api.flutter.dev/flutter/material/Easing/standard-constant.html + - https://api.flutter.dev/flutter/material/Easing/standardAccelerate-constant.html + - https://api.flutter.dev/flutter/material/Easing/standardDecelerate-constant.html + - https://api.flutter.dev/flutter/material/Easing/emphasizedAccelerate-constant.html + - https://api.flutter.dev/flutter/material/Easing/emphasizedDecelerate-constant.html + +### Internal empirical source +- Implementation and local validation log in `draft.txt`: + - wrapper split (`.m3-popper-positioner` + `.m3-popper`) + - updated E2E assertions + - local check commands and outcomes + +## Extracted Facts +### Direct facts +1. Positioning and animation transforms conflict if both are applied to the same element. +2. Splitting responsibilities into two elements is technically viable: + - outer element handles geometry (`absolute/top/left/transform` from floating-ui), + - inner element handles visual animation (`transform/opacity/visibility`). +3. Show ordering matters: first position (`await adjust`), then reveal content. +4. Animation direction must use the effective side after flip (actual placement), not requested placement. +5. Headless access to `m3.material.io` pages may be limited (JS-required shell), so token tables were taken from official generated/tokenized sources. +6. Local verification was reported as green after stabilization: + - `tsc` for foundation/react/vue, + - `eslint`, + - unit and e2e checks for popper, + - combined coverage run (`80.11%`). + +### Motion token facts (from token references) +1. Duration token scale: + - short: 50/100/150/200 ms, + - medium: 250/300/350/400 ms, + - long: 450/500/550/600 ms, + - extra long: 700/800/900/1000 ms. +2. Easing token curves: + - standard: `cubic-bezier(0.2, 0.0, 0.0, 1.0)`, + - standardAccelerate: `cubic-bezier(0.3, 0.0, 1.0, 1.0)`, + - standardDecelerate: `cubic-bezier(0.0, 0.0, 0.0, 1.0)`, + - emphasizedAccelerate: `cubic-bezier(0.3, 0.0, 0.8, 0.15)`, + - emphasizedDecelerate: `cubic-bezier(0.05, 0.7, 0.1, 1.0)`, + - linear: `cubic-bezier(0.0, 0.0, 1.0, 1.0)`. + +### Inferences used for implementation tuning +1. Enter animation should prefer decelerate-family easing, exit should prefer accelerate-family easing. +2. Perceived "menu unfolding from anchor point" depends on: + - side-aware `transform-origin`, + - axis-dominant scale (uncollapse) with small translation offset. +3. Candidate presets for iterative tuning: + - `short3/short1` or `short4/short2`, + - decelerate for enter + accelerate for exit. + +## Notes for future work +- Keep parity of popper behavior checks in React and Vue E2E. +- Freeze chosen duration/easing pair in tests via computed-style assertions to prevent regressions. diff --git a/evidence/surface/fetch/bottom-sheets.json b/evidence/surface/fetch/bottom-sheets.json new file mode 100644 index 0000000..3f8310e --- /dev/null +++ b/evidence/surface/fetch/bottom-sheets.json @@ -0,0 +1 @@ +{"category": "containment", "component_image_1x1": {}, "component_image_2x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/341c3402df784b026de4b8b6fe23f3eeddcfc45e753bc872f59861c10edde81bfb0410c8b1838a86255f0f66b47581ff3621044502dee57db2fb365ca71021da", "height": 560, "url": "https://lh3.googleusercontent.com/UIUrt9wQoicIdyQ4IjklJLL6-vIR-GmyYF4ErZOfLrFLREEII6mOVuE15ImA8sq6VvneWK0KQvVOFh8nR_39hUdwN2YJMT7OX9Bivo7aPBBPj0zakQQ", "width": 1120}, "component_image_3x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/c2b9b97f5b43d4d8edfb0efa2cae3a4ede8e4e442ea0c5231f5309e37613166abbfb485a7f96a16f06bdd58a0abeebaa7a24aed225d635193aaa2e07fb0de0f1", "height": 560, "url": "https://lh3.googleusercontent.com/2YiNco_ni2-lSwWlJuYpcp0SKqQQzz1FtoDMeqvd5rjvkTenfvcYm3ccSqJh2oowZ_bXvcUnrMvRzLXrR4HT-3iwlA25df97JZBiIAeOfZEoSHeldA", "width": 1680}, "component_image_3x2": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/14d3cfe953f0b131da2966f35d9437ba2065e16f2d584c8da4192c45a2ba1937052ca085c386576e924d0cdb2c454593054b9742c644f4e57a541c1e5eed5a04", "height": 560, "url": "https://lh3.googleusercontent.com/vs0KnopDGwWY_DYPbdNdmkyYL_N4Cf3eOG7StgUi-LFVsgXqBJ0k4Jlgw25QLpCuxUFPk8Rwt3ijxuBgIohNjVQsEAIqvOB7EzObKxSQzbRyNgGVEYXG", "width": 840}, "component_image_position": "top center", "dark_component_image_1x1": {}, "dark_component_image_2x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/6442b37617d160c37751d4301b1265a781d2c786aed628fa890892d2e191038e5b987ff74363ccd261904eb0d686dbabd2e356107badfeaf57b6e13b629e0697", "height": 560, "url": "https://lh3.googleusercontent.com/aOb9-hvKVti3KeEn-NfoD-f1IonRWbrz-A26gJ4SSuakHtKHC2Vw_uh1w3E_1JzMAx4CQ0tjpi2Zvq4yzQ60whrjOAzyIq2CcjAzMgx5VcbGpNb1h4cA", "width": 1120}, "dark_component_image_3x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/2e5a55f30d6a8841040c2e2d947f4039c5713cb5144318a02d5a1ef5a2d9b4fafb5f3f89af22ad89876c5f2fdbc5be393b23f2457e0829d2ed877d61301bec50", "height": 560, "url": "https://lh3.googleusercontent.com/2eharb9N0r0D_CQm81EpHY7FtjRm1hfksH7QVW09EsH2Wx6OwToQyFaWpUmqqGqtk3r30XIf42o1GeDOwjmkZtLPivrY0LyV0q2avatZVgKk8U0Gyw", "width": 1680}, "dark_component_image_3x2": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/31f1e3efd779bd9bd956ab312e0107409d00de80ef55beb7a4fcc5e1588da9b88e3d4a5281002ff43de1ee61169a9538a9544015a620d9a2445c2f072c66e094", "height": 560, "url": "https://lh3.googleusercontent.com/3MqGJTtT0PiPqw6-AG2hxNyEw3EN7kfRuPQBbq4jAd5tU2lDf2eBPFzXyEmoe9iPQH1RfQaoql6df_4tuSKQli87ijf1aWl8JqoyarmV1FM36g33Gg", "width": 840}, "description": "Bottom sheets are surfaces containing supplementary content, anchored to the bottom of the screen.", "hero": {"background_asset": {}, "background_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/c9b614e2c6394e633361aedf8dfabee7018abc81fed5e9c2d2b830cfb91cdde77617375d80608ab19e9deaae6e65763ecf7ee7e7f3be1c5cb05eb08a89e09fe0", "height": 4096, "url": "https://lh3.googleusercontent.com/9gfaUBF_jWA_0uIpfK8gwcKthghUalMNBXP1_ILmkMAF8uf8q8xV3FPeiK2qJYfUyoUHf0zrQooP-olwdgm3nEfSIah6Do202yLoBMuFyl4nao29ao8", "width": 4096}, "foreground_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/533e014f36dc2451d394c03188633b3e805049119e1fdac9903be32e3e3163588ccfb610d365c2e1ada576174426cea0101031cbad8245a346d8fc1a88d4fff6", "height": 1816, "url": "https://lh3.googleusercontent.com/xG7HTNDPwS9Dj37Bw-sK9tqCB7RKZkDDYhw6oYHKxmEio1ZdY7ilg1TGGLteoAI14_qj3ZCMj4gbXa8NebMrGyYRKRcQ-5wqcMCdw6BeuTkI_P2gvOU", "width": 856}, "foreground_image_alignment": "center", "foreground_image_svg": null, "foreground_text_light": false}, "ia_title": "Components / Bottom Sheets", "isDisplayed": true, "jetpack": {}, "mdc_android": {"banners": [], "hide_generic_not_available_message": false, "hide_intro_block": true, "image": [{"a11y_description": "Example bottom sheet: modal bottom sheet", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/14fa0f73686498345e6b3d2c162cc038612f0ce475969b6223543977af215f0dd3a5d4473a594d5c4cb7cfb8f79f7fdb5f97f9159c372a2853111143861d1685", "height": 532, "url": "https://lh3.googleusercontent.com/Um-uopzOwBakYytrS-_tys-4Jhe_bwbY-rVuzx9swVBZMcizNX_w985A8GdRQ-FwbEW45mMfDgkiuMMsqi3JI9fqa6FHb0oSlvM4cwFEog1sSlicsYr-", "width": 1064}}, {"a11y_description": "Standard bottom sheet example. Collapsed on the left and expanded on the\nright.", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/b08f62e5cc779362c32c8360c3015ea601124ece798a056ab276b565e117d0deaa774e0c476230b7dd796ce8443cbf34f9265ecc0027944f8cfe742a753139e2", "height": 2160, "url": "https://lh3.googleusercontent.com/B7t0IbDHwFQuL7iRuKhTuTpZo8mblYAHg0q-bCowsAn8fpizoxpWnQfK0E9Zb-g_6DownUBiSOA6NdaeCoVEd_DKdR1P8qbeP9dCQFXy0uz9ngGNeg", "width": 2160}}, {"a11y_description": "Modal bottom sheet example. Collapsed on the left and expanded on the right.", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/f810ef2649599d0c1993697c41e609a37fb93773f5c7c09d52bb10e728e087c2f28c36b2cfe17bb8aa8c59725ba172f463b1723247f42cdac16d2379db1d52e4", "height": 2160, "url": "https://lh3.googleusercontent.com/ajuNBwFkxERvKgQ6Pk4e_ZOq8CPWj5f7aA_uYD6Qq5Tv1bYy1-jLKIhd_TK5EerJ550N0JnoyQ_fZ6_dEXC9g-hAWxe2GGamCxGEnsG1n_YxNVaqwQ", "width": 2160}}, {"a11y_description": "Bottom sheet anatomy", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/28bf359e28ada5a30a7b335b4ba3860c8a323c572cf1aa1b9d567e2caf99b6f39df87bbe15aa14ad6a4a8632ed0ec78b5e1c1051f34868f47e7ecddc1f3f550d", "height": 760, "url": "https://lh3.googleusercontent.com/UQIEJJ9UEOR3t5g9wtTOGoWy6s8QXbsxKYOdt7RJ6bSmINikBLnZ3XbSpIb9RBtQwY811TZCw9Zs57eg85AjERuRB4bE7LR08qZZsJLD21O4qRIjJQaX", "width": 1520}}, {"a11y_description": "Bottom sheet with pink background color. Collapsed on the left and expanded on\nthe right.", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/8f7f2b0ea75fdb81c0988892e73059dd272b71c910b181096c5f963c88e47c780bc83ce2dbb100b550248173a0b64523d8cb29b3ef19c636d117877d24e1a434", "height": 830, "url": "https://lh3.googleusercontent.com/5KpmGZgdsLzmO52VnZGPD5JqniKVgQ1dynaKlS948Igjo0NmW5h1MVQibtW2ZD6-RoSUraRWU1PRlvgie1J1_1Q2A3opYG9gyGsBOzpzwsR4HKJTLZQE", "width": 800}}], "is_beta": false, "markdown_content": "\n\n# Bottom Sheets\n\n[Bottom sheets](https://material.io/components/sheets-bottom) are surfaces\ncontaining supplementary content that are anchored to the bottom of the screen.\n\n\n\n**Contents**\n\n* [Using bottom sheets](#using-bottom-sheets)\n* [Standard bottom sheet](#standard-bottom-sheet)\n* [Modal bottom sheet](#modal-bottom-sheet)\n* [Anatomy and key properties](#anatomy-and-key-properties)\n* [Theming](#theming-bottom-sheets)\n\n## Using bottom sheets\n\nBefore you can use Material bottom sheets, you need to add a dependency to the\nMaterial Components for Android library. For more information, go to the\n[Getting started](/libraries/mdc-android/getting-started)\npage.\n\nStandard bottom sheet basic usage:\n\n```xml\n\n\n \n\n \n\n \n\n\n```\n\nModal bottom sheet basic usage:\n\n```kt\nclass ModalBottomSheet : BottomSheetDialogFragment() {\n\n override fun onCreateView(\n inflater: LayoutInflater,\n container: ViewGroup?,\n savedInstanceState: Bundle?\n ): View? = inflater.inflate(R.layout.modal_bottom_sheet_content, container, false)\n\n companion object {\n const val TAG = \"ModalBottomSheet\"\n }\n}\n\nclass MainActivity : AppCompatActivity() {\n ...\n val modalBottomSheet = ModalBottomSheet()\n modalBottomSheet.show(supportFragmentManager, ModalBottomSheet.TAG)\n ...\n}\n```\n\nMore information on each individual section, below.\n\n### Setting behavior\n\nThere are several attributes that can be used to adjust the behavior of both\nstandard and modal bottom sheets.\n\nBehavior attributes can be applied to standard bottom sheets in xml by setting\nthem on a child `View` set to `app:layout_behavior`, or programmatically:\n\n```kt\nval standardBottomSheetBehavior = BottomSheetBehavior.from(standardBottomSheet)\n// Use this to programmatically apply behavior attributes\n```\n\nBehavior attributes can be applied to modal bottom sheets using app-level theme\nattributes and styles:\n\n```xml\n\n\n\n\n\n```\n\nOr programmatically:\n\n```kt\nval modalBottomSheetBehavior = (modalBottomSheet.dialog as BottomSheetDialog).behavior\n// Use this to programmatically apply behavior attributes\n```\n\nMore information about these attributes and their default values is available in\nthe [behavior attributes](#behavior-attributes) section.\n\n### Retaining behavior on configuration change\n\nIn order to save and restore specific behaviors of the bottom sheet on\nconfiguration change, the following flags can be set (or combined with bitwise\nOR operations):\n\n* `SAVE_PEEK_HEIGHT`: `app:behavior_peekHeight` is preserved.\n* `SAVE_HIDEABLE`: `app:behavior_hideable` is preserved.\n* `SAVE_SKIP_COLLAPSED`: `app:behavior_skipCollapsed` is preserved.\n* `SAVE_FIT_TO_CONTENTS`: `app:behavior_fitToContents` is preserved.\n* `SAVE_ALL`: All aforementioned attributes are preserved.\n* `SAVE_NONE`: No attribute is preserved. This is the default value.\n\nBehaviors can also be set in code:\n\n```kt\nbottomSheetBehavior.saveFlags = BottomSheetBehavior.SAVE_ALL\n```\n\nOr in xml using the `app:behavior_saveFlags` attribute.\n\n### Setting state\n\nStandard and modal bottom sheets have the following states:\n\n* `STATE_COLLAPSED`: The bottom sheet is visible but only showing its peek\n height. This state is usually the 'resting position' of a bottom sheet, and\n should have enough height to indicate there is extra content for the user to\n interact with.\n* `STATE_EXPANDED`: The bottom sheet is visible at its maximum height and it\n is neither dragging nor settling (see below).\n* `STATE_HALF_EXPANDED`: The bottom sheet is half-expanded (only applicable if\n `behavior_fitToContents` has been set to false), and is neither dragging nor\n settling (see below).\n* `STATE_HIDDEN`: The bottom sheet is no longer visible and can only be\n re-shown programmatically.\n* `STATE_DRAGGING`: The user is actively dragging the bottom sheet up or down.\n* `STATE_SETTLING`: The bottom sheet is settling to a specific height after a\n drag/swipe gesture. This will be the peek height, expanded height, or 0, in\n case the user action caused the bottom sheet to hide.\n\nYou can set a state on the bottom sheet:\n\n```kt\nbottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED\n```\n\n**Note:** `STATE_SETTLING` and `STATE_DRAGGING` should not be set programmatically.\n\n### Listening to state and slide changes\n\nA `BottomSheetCallback` can be added to a `BottomSheetBehavior`:\n\n```kt\nval bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {\n\n override fun onStateChanged(bottomSheet: View, newState: Int) {\n // Do something for new state.\n }\n\n override fun onSlide(bottomSheet: View, slideOffset: Float) {\n // Do something for slide offset.\n }\n}\n\n// To add the callback:\nbottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback)\n\n// To remove the callback:\nbottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback)\n```\n\n### Handling insets and fullscreen\n\n`BottomSheetBehavior` can automatically handle insets (such as for\n[edge to edge](https://developer.android.com/training/gestures/edge-to-edge)) by\nspecifying any of these to true on the view:\n\n* `app:paddingBottomSystemWindowInsets`\n* `app:paddingLeftSystemWindowInsets`\n* `app:paddingRightSystemWindowInsets`\n* `app:paddingTopSystemWindowInsets`\n\nOn API 21 and above the modal bottom sheet will be rendered fullscreen (edge to\nedge) if the navigation bar is transparent and `app:enableEdgeToEdge` is true.\nTo enable edge-to-edge by default for modal bottom sheets, you can override\n`?attr/bottomSheetDialogTheme` like the below example:\n\n```\n\n\n\n```\n\nInsets can be added automatically if any of the padding attributes above are set\nto true in the style, either by updating the style passed to the constructor, or\nby updating the default style specified by the `?attr/bottomSheetDialogTheme`\nattribute in your theme.\n\n`BottomSheetDialog` will also add padding to the top when the bottom sheet\nslides under the status bar, to prevent content from being drawn underneath it.\n\n### Making bottom sheets accessible\n\nThe contents within a bottom sheet should follow their own accessibility\nguidelines, such as setting content descriptions for images.\n\nTo support dragging bottom sheets with accessibility services such as TalkBack,\nVoice Access, Switch Access, etc., we provide a convenient widget\n`BottomSheetDragHandleView` which will automatically receive and handle\naccessibility commands to expand and collapse the attached bottom sheet when\nthe accessibility mode is enabled. To use `BottomSheetDragHandleView`, you can\nadd it to the top of your bottom sheet content. It will show a customizable\nvisual indicator for all users. See the example in the below section for how to\nadd a drag handle to your bottom sheet.\n\n**Note:** `BottomSheetDragHandleView` has a default min width and height of 48dp\nto conform to the minimum touch target requirement. So you will need to preserve\nat least 48dp at the top to place a drag handle.\n\n## Standard bottom sheet\n\nStandard bottom sheets co-exist with the screen\u2019s main UI region and allow for\nsimultaneously viewing and interacting with both regions. They are commonly used\nto keep a feature or secondary content visible on screen when content in the\nmain UI region is frequently scrolled or panned.\n\n`BottomSheetBehavior` is applied to a child of\n[CoordinatorLayout](https://developer.android.com/reference/androidx/coordinatorlayout/widget/CoordinatorLayout)\nto make that child a **persistent bottom sheet**, which is a view that comes up\nfrom the bottom of the screen, elevated over the main content. It can be dragged\nvertically to expose more or less content.\n\nAPI and source code:\n\n* `BottomSheetBehavior`\n * [Class definition](https://developer.android.com/reference/com/google/android/material/bottomsheet/BottomSheetBehavior)\n * [Class source](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java)\n\n### Standard bottom sheet example\n\nThe following example shows a standard bottom sheet in its collapsed and\nexpanded states:\n\n\n\n`BottomSheetBehavior` works in tandem with `CoordinatorLayout` to let you\ndisplay content on a bottom sheet, perform enter/exit animations, respond to\ndragging/swiping gestures, etc.\n\nApply the `BottomSheetBehavior` to a direct child `View` of `CoordinatorLayout`:\n\n```xml\n\n\n \n\n \n \n\n \n \n\n \n\n \n\n \n\n \n\n\n```\n\nIn this example, the bottom sheet is the `FrameLayout`.\n\n## Modal bottom sheet\n\nModal bottom sheets present a set of choices while blocking interaction with the\nrest of the screen. They are an alternative to inline menus and simple dialogs\non mobile devices, providing additional room for content, iconography, and\nactions.\n\n`BottomSheetDialogFragment` is a thin layer on top of the regular support\nlibrary Fragment that renders your fragment as a **modal bottom sheet**,\nfundamentally acting as a dialog.\n\nModal bottom sheets render a shadow on the content below them, to indicate that\nthey are modal. If the content outside of the dialog is tapped, the bottom sheet\nis dismissed. Modal bottom sheets can be dragged vertically and dismissed by\nsliding them down completely.\n\nAPI and source code:\n\n* `BottomSheetDialogFragment`\n * [Class definition](https://developer.android.com/reference/com/google/android/material/bottomsheet/BottomSheetDialogFragment)\n * [Class source](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/bottomsheet/BottomSheetDialogFragment.java)\n\n### Modal bottom sheet example\n\nThe following example shows a modal bottom sheet in its collapsed and expanded\nstates:\n\n\n\nFirst, subclass `BottomSheetDialogFragment` and overwrite `onCreateView` to\nprovide a layout for the contents of the sheet (in this example, it's\n`modal_bottom_sheet_content.xml`):\n\n```kt\nclass ModalBottomSheet : BottomSheetDialogFragment() {\n\n override fun onCreateView(\n inflater: LayoutInflater,\n container: ViewGroup?,\n savedInstanceState: Bundle?\n ): View? = inflater.inflate(R.layout.modal_bottom_sheet_content, container, false)\n\n companion object {\n const val TAG = \"ModalBottomSheet\"\n }\n}\n```\n\nThen, inside an `AppCompatActivity`, to show the bottom sheet:\n\n```kt\nval modalBottomSheet = ModalBottomSheet()\nmodalBottomSheet.show(supportFragmentManager, ModalBottomSheet.TAG)\n```\n\n`BottomSheetDialogFragment` is a subclass of `AppCompatFragment`, which means\nyou need to use `Activity.getSupportFragmentManager()`.\n\n**Note:** Don't call `setOnCancelListener` or `setOnDismissListener` on a\n`BottomSheetDialogFragment`. You can override\n`onCancel(DialogInterface)` or `onDismiss(DialogInterface)` if necessary.\n\n## Anatomy and key properties\n\nBottom sheets have a sheet, content, and, if modal, a scrim.\n\n\n\n1. Sheet\n2. Content\n3. Scrim (in modal bottom sheets)\n\n### Sheet attributes\n\nElement | Attribute | Related method(s) | Default value\n-------------- | --------------------- | --------------------------------- | -------------\n**Color** | `app:backgroundTint` | N/A | `?attr/colorSurface`\n**Shape** | `app:shapeAppearance` | N/A | `?attr/shapeAppearanceLargeComponent`\n**Elevation** | `android:elevation` | N/A | `3dp`\n**Max width** | `android:maxWidth` | `setMaxWidth`
`getMaxWidth` | `640dp`\n**Max height** | `android:maxHeight` | `setMaxHeight`
`getMaxHeight` | N/A\n\n### Behavior attributes\n\nMore info about these attributes and how to use them in the\n[setting behavior](#setting-behavior) section.\n\nBehavior | Related method(s) | Default value\n------------------------------------------- | ------------------------------------------------------------------------- | -------------\n`app:behavior_peekHeight` | `setPeekHeight`
`getPeekHeight` | `auto`\n`app:behavior_hideable` | `setHideable`
`isHideable` | `false` for standard
`true` for modal\n`app:behavior_skipCollapsed` | `setSkipCollapsed`
`getSkipCollapsed` | `false`\n`app:behavior_fitToContents` | `setFitToContents`
`isFitToContents` | `true`\n`app:behavior_draggable` | `setDraggable`
`isDraggable` | `true`\n`app:behavior_halfExpandedRatio` | `setHalfExpandedRatio`
`getHalfExpandedRatio` | `0.5`\n`app:behavior_expandedOffset` | `setExpandedOffset`
`getExpandedOffset` | `0dp`\n`app:behavior_significantVelocityThreshold` | `setSignificantVelocityThreshold`
`getSignificantVelocityThreshold` | `500 pixels/s`\n\nTo save behavior on configuration change:\n\nAttribute | Related method(s) | Default value\n------------------------ | --------------------------------- | -------------\n`app:behavior_saveFlags` | `setSaveFlags`
`getSaveFlags` | `SAVE_NONE`\n\n### Styles\n\n**Element** | **Default value**\n------------------------- | -------------------------------------------\n**Default style (modal)** | `@style/Widget.Material3.BottomSheet.Modal`\n\nDefault style theme attribute:`?attr/bottomSheetStyle`\n\nNote: The `?attr/bottomSheetStyle` default style theme attribute is for modal\nbottom sheets only. There is no default style theme attribute for standard\nbottom sheets, because `BottomSheetBehavior`s don't have a designated associated\n`View`.\n\n### Theme overlays\n\n**Element** | **Theme overlay**\n------------------------- | ------------------------------------------\n**Default theme overlay** | `ThemeOverlay.Material3.BottomSheetDialog`\n\nDefault theme overlay attribute: `?attr/bottomSheetDialogTheme`\n\nSee the full list of\n[styles](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/bottomsheet/res/values/styles.xml),\n[attrs](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/bottomsheet/res/values/attrs.xml),\nand\n[themes and theme overlays](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/bottomsheet/res/values/themes.xml).\n\n## Theming bottom sheets\n\nBottom sheets support\n[Material Theming](https://material.io/components/sheets-bottom#theming), which\ncan customize color and shape.\n\n### Bottom sheet theming example\n\nAPI and source code:\n\n* `BottomSheetBehavior`\n * [Class definition](https://developer.android.com/reference/com/google/android/material/bottomsheet/BottomSheetBehavior)\n * [Class source](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java)\n* `BottomSheetDialogFragment`\n * [Class definition](https://developer.android.com/reference/com/google/android/material/bottomsheet/BottomSheetDialogFragment)\n * [Class source](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/bottomsheet/BottomSheetDialogFragment.java)\n\nThe following example shows a bottom sheet with Material Theming, in its\ncollapsed and expanded states.\n\n\n\n#### Implementing bottom sheet theming\n\nSetting the theme attribute `bottomSheetDialogTheme` to your custom\n`ThemeOverlay` will affect all bottom sheets.\n\nIn `res/values/themes.xml`:\n\n```xml\n\n\n\n```\n\nIn `res/values/styles.xml`:\n\n```xml\n\n\n\n```\n\n**Note:** The benefit of using a custom `ThemeOverlay` is that any changes to\nyour main theme, such as updated colors, will be reflected in the bottom sheet,\nas long as they're not overridden in your custom theme overlay. If you use a\ncustom `Theme` instead, by extending from one of the\n`Theme.Material3.*.BottomSheetDialog` variants, you will have more control over\nexactly what attributes are included in each, but it also means you'll have to\nduplicate any changes that you've made in your main theme into your custom\ntheme.\n", "markdown_path": "docs/components/BottomSheet.md", "meta": {"keywords": null, "meta_robots": " ", "schema_structure": "{}", "share_description": null, "share_image": null, "share_title": null}, "title": "Bottom sheets", "up_next_section": {"items": []}, "document_id": "4640185669255168"}, "mdc_flutter": {"banners": [], "hide_generic_not_available_message": false, "hide_intro_block": true, "image": [], "is_beta": false, "markdown_content": null, "markdown_path": null, "meta": {"keywords": null, "meta_robots": " ", "schema_structure": "{}", "share_description": null, "share_image": {}, "share_title": null}, "title": "Bottom Sheets", "up_next_section": {"items": []}, "document_id": "5472253978476544"}, "mdc_web": {}, "metadata": {"keywords": "Material, Material Design, Google, Material Design 3, Material 3, Material You, Material Design Components, Material Components, components, cards", "meta_robots": null, "schema_structure": "{}", "share_description": "Bottom sheets show secondary content anchored to the bottom of the screen. There are two types of bottom sheets: standard and modal.", "share_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/c953bf9b2efdd14a850224135254c8b148a26adbce86d8a93dcf42439deb393f1ebbb519dc761df6c1569e1c08f757a88e4d533cc82dc8c7f4d25b16b781fb79", "height": 560, "url": "https://lh3.googleusercontent.com/anRizsTTKb3ZFDxvaOQnGhnaepLiJtk__kY2eOG1-X9tBEOZ_Efjd8vHU14mujMBUuRZXdKZjW6kZp32adjusmMOSOrgzXKXqvJHUUkEDF9C-tteNDyQ", "width": 1120}, "share_title": "Bottom sheets \u2013 Material Design 3"}, "page_hero": {"foreground_image_1x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/423127aef1c64cb6a4d543d785647510ee752773c1e3730cd2aedeea5ce998522efed9ab837c46053e0b59e94b383b0626daf465340c81b7c98b05e944f9318d", "height": 1120, "url": "https://lh3.googleusercontent.com/LYDT9qYA1q7NLsDc9uG-q6ADc8znPvqUZ78u1oAg28gAsH6KDuUou2jk_OBbds4h3MDfZ0HwStPi5LPeXDGvpm7CkL1me_RlYTWXsHBmtW-FAYY-nXE", "width": 1120}, "foreground_image_1x1_dark_mode": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/2b1aa6610cb93219f521691df9d488f81e51d42cdf8515a454ad22f7f2820ed3c35ec81f42f80b2715a47e0d9cc6cff730ff516220572fa989ad9ccb7f1573e7", "height": 1120, "url": "https://lh3.googleusercontent.com/G9867ICt9b-lncJHHeVCxGxXlAKtIHq2Mzk4exKf7IkmAbj663wW-c4gIUsyeT0JfuNm8rrf1Hmo1wdCDS5AH0zScZNH8SLxn0iIi_58kS--vmVqPg", "width": 1120}, "foreground_image_2x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/f27e561a36c757333bc0e1671f30bf024bf5fc42d120495a9f443ce9cd207fa573c01d10b4ea977586470eeca1a134949841971504e59d1d12d34543974f2d72", "height": 1000, "url": "https://lh3.googleusercontent.com/6Xkb4jV_z0luNQskg19qC2PaETvm-JM49LlNOeqzfFiSXSmwae6J93dXBo7Jc442xJdZSENioqa28IsM406PXN_bPxo53-4kw5ZpXyj1lGKzOC2ORfSS", "width": 2000}, "foreground_image_2x1_dark_mode": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/22fc7f4780841efbb4c9acc5c9134eb76d84aec484c21dcc003c134fa90b77495aa6ed31efaaeec851096e2746ca4660aa396a49c45ed1726c6b1552b883d75c", "height": 1000, "url": "https://lh3.googleusercontent.com/XCWmA6moCnvUU5E2-0TNFM3MY2l4DlNkwRzwrjbD_ZHXcP-h2H02Y6UZ9juXVm4O0Eliok1wjM7kc4mZGRfRKEW_lS0DAk_qJdpPkG6gWtKozPpGOnfY", "width": 2000}, "full_width": false, "full_width_content_centered": false, "horizontal_foreground_alignment_override": null, "main_background_image": {}, "main_background_image_dark_mode": {}, "split_background_asset": {}, "split_background_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/6ab37909dc8478dd5e56137ee989c11507af974de96d6b52d7b04c096eaf500eb6cb297ecbc6d68d676634ef0c77bc196b4b50daf9101baf0a393f0d4e4d70c5", "height": 4096, "url": "https://lh3.googleusercontent.com/Zepnyf8JSnn6jKK6Zq6Ppm1oWSVdUMujaWc3pp-TMsk0U5THxsPYusqEIccnUary6eohY1UoVBgB7bQQ9FRgawuvXKDAPy2EPZwj6D9zgkRPV-breddj", "width": 4096}, "split_background_image_dark_mode": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/11266e9deb9db2deaab0114997d42033baa68f7cdc695f7f62dae5722a76b2e4787c5b96f0193b88c26968894dfbc41cc8fb0d07608fb463b21f5afd20ff238b", "height": 4096, "url": "https://lh3.googleusercontent.com/ewqelGlD5dUrp4LyDl4uyaS1aEAT0LqmTGwUblaNx6UlioWljMsus4141fu-waKli5dgu71O2g42Kl8owxFv6ijaFo_HTSnexyGaVHwF1DUqPEfW0Q", "width": 4096}, "vertical_foreground_alignment_override": null}, "title": "Bottom sheets", "document_id": "5317529291587584", "modified_date": "2025-04-25"} \ No newline at end of file diff --git a/evidence/surface/fetch/dialogs.json b/evidence/surface/fetch/dialogs.json new file mode 100644 index 0000000..6696384 --- /dev/null +++ b/evidence/surface/fetch/dialogs.json @@ -0,0 +1 @@ +{"category": "containment", "component_image_1x1": {}, "component_image_2x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/1201b10f2aa29cc2b012aedf3d6767fa7b34b9a502457c68e1a6ef87c1b3d8c35e2f631bec3c785a03d0ee6e560e2004e6cf4527dc1faf03206476fde49d502f", "height": 560, "url": "https://lh3.googleusercontent.com/CFJfp5JSKy1CCK9X0jVO7pRomxEXLpvUPlj6biCd8V1zkvQAdNnnhWqE-pMeX8nol5yx9YwMikRapiuiahQLUEufqpxDJxs4w00HrUNUMcxLftTcddA", "width": 1120}, "component_image_3x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/a8ee8e7fdedecf347ab7be12bdbe17ae43a145b06f8b6849806808fe705a329dae84930131c39debda19c5ebc03d60d93b102e96323f1a342e65c42354cb52e8", "height": 560, "url": "https://lh3.googleusercontent.com/vA2EVTxGzbpxA8uKcqA_kUbswA5Ip9NL9iuDq6XXU4DlNyAFNQaXu5H0JslNpWvfo0wqTABMQchMT2toI5YQFByNKhkty8pwj3cZXIXBsw0Hd5PwdBlx", "width": 1680}, "component_image_3x2": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/667710b7bb2a7ea03afea363090125566b582bf944873a5fc55e158fc76d05a18f9784140bef9f4d631fe0392e2386d81aa854e59279c438d36a7e2cda9049c3", "height": 560, "url": "https://lh3.googleusercontent.com/asmiS26l6fx3OYk-LUie65WSh26b_c1zjpLUODiitaz_ICoFGQz-hyrnid0pw4GiCWNGy4incRNik8xuTNWjaMyPjKUQo5njyXuwdlm7A8yAFlCZIg", "width": 840}, "component_image_position": "center", "dark_component_image_1x1": {}, "dark_component_image_2x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/994d5fde6374e3edf539c37a962337f22e7ebcecb254b5b8f0b624245e2a34148282576570acac992f31fd10ad56d328f63e06536d38a5dfb0e0112711bcbe73", "height": 560, "url": "https://lh3.googleusercontent.com/8r_6V4X-Rw2Iywz74S0SHRsbe1Dghi2GmPHKHTqQ_AkOKhjWXsiJ9RUGx4l8n5WT17AHdj9E2ycJlIzqqPWJJ0Lii3C-WgXV1em_EEEtl4t0H1UGULg", "width": 1120}, "dark_component_image_3x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/cf8967735ecfa675a864c392e728b94723b3d365ac8e0334c776fa4d2a3ed869e6a7514cd0f0671c753f33d891fd49a4d84450b8c05482c95e261603268075ba", "height": 560, "url": "https://lh3.googleusercontent.com/kVRpL11HJpgVz1WwKXoePGQs50bzRfd_vrk1gntt3VMrQDniwtkEinIYOj7CDeKh4j38EaxuR4OIXHao0ahKelQyT2fvNvB6-qMw7pplTaARrXLP9r-F", "width": 1680}, "dark_component_image_3x2": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/720bedc77eae4ce24f71b551978a503ba999d9518f626b3478efe9166a76765ce767f84b59d5952c9bcca3cdc69fcac897ca20503ed5b0418fd57dee4cdca900", "height": 560, "url": "https://lh3.googleusercontent.com/UxQqwGi4wvijSnl77_1OLF9jpxNnS6zoadsG94JlTwHVusL-jOc5M2EMECCgSpuhp7euZlXdo2dj4s5o23F3puPqaLS6mS91DVS8ANeQGbNsGIrYBw", "width": 840}, "description": null, "hero": {"background_asset": {}, "background_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/374704d69859062037341f8f902b1efd04a623e626051d8249b8cd4ffe237f131bb0259de564e978f96a5ad0e3386721618b3cbb1f01890c63d94109c0f84fcc", "height": 4096, "url": "https://lh3.googleusercontent.com/p5D8lSFRzHHtJeJh9glEIalFFsCBHp0PBPImeTzekk3IE8mtEYxCMNXaj0TffIlcPkdFOjvjLf6Zp7SZDpNOA2e6M6jJNHoA5jRV5V0iSSE8AAljlZ4", "width": 4096}, "foreground_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/96b77eadf1dc3f9f97ed94edfdbe69a7716114344cb29f3a4ab0e9265945e617679c07754f984ed5d53fca0741176f1973d95bd76928ec9dd845487c81db3c1b", "height": 1816, "url": "https://lh3.googleusercontent.com/cD2FTVbrqtc_pZ7IwitArkWuRFGvQ_CHj-cuD76UiDUZZpjY2F0EmeUPmdLdf29NQKITPu540wiWTGIz4CbCZFzE_REolC9FEWXM6_pShckgW2Wg9Q", "width": 856}, "foreground_image_alignment": "center", "foreground_image_svg": null, "foreground_text_light": false}, "ia_title": "Components / Dialogs", "isDisplayed": true, "jetpack": {}, "mdc_android": {"banners": [{"description": "

Dialogs are available in the Material Design 3 library for Jetpack Compose. For implementation details, visit the androidx.compose.material3 library documentation on Android Developers:

", "links": [{"aria_label": "Read about implementing Material Design 3 AlertDialogs using Jetpack Compose", "href": "https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#alertdialog", "label": "AlertDialog API reference", "open_new_window": true}], "title": "Jetpack Compose support available"}], "hide_generic_not_available_message": true, "hide_intro_block": true, "image": [{"a11y_description": "Examples of the two types of dialogs.", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/4702378141911baecc1e6721afe2722cd953dbcece8c607f27ad431a1d2f3c59255d1b291781cfc8ea2251a4a42395ec674cec71ee85f8eb9712bffc963792d1", "height": 1036, "url": "https://lh3.googleusercontent.com/U3KG3u3LdCQsgAwfJMpOsk_RAZF1qYzBg-IVOQIqZEMmicfZu-1hHHHtfPTORjuMlP6zl5-gBV7ZMGoCxvQpGhL9_605HTMGUmVBBlSc8auT_9lV", "width": 1999}}, {"a11y_description": "\"Dialog with title, and text and purple buttons to cancel, decline or accept\"", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/641d369239c3c8122c7504ec20bb4734d7fa5e82211b760e7a2b353cafe5120bba9117af0a26e7d3726d8c9384f52e5c083ab8cd6df4b7eb8cea49f90703c8ed", "height": 687, "url": "https://lh3.googleusercontent.com/kEgwg9iNd9jboCKEfFrhvyJYod5D4ZALPrNd5Ru63Cqy4OG_FCX7zs8RU5AKBp1KrJxZzhGvu1QWG8dv1Z4Ngc0BYp8CChsqKcV8YFhnddJqRJU50ZQ", "width": 1080}}, {"a11y_description": "anatomy", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/34848ab749d6ff65f57f82b2c5ff5a54fe75ee3e7edafbe09cd860b75f16216d8409c6d0a3e044f13e4de1dd4c0db6b0f2c692c8265af4cc422f2939523b3b2e", "height": 970, "url": "https://lh3.googleusercontent.com/7GNdJqfCwAzo8wn9yzGsmGPeAFU3R3s6_A3VCdKX9u9cpGmQeCgnYni2t6CleU4bMEq01sBxsgQsNRJ2agc6lEdo5-j2angIeg87yqOTOTaaQS8Awbo", "width": 1410}}, {"a11y_description": "Dialog with title and text buttons in brown and selected radio button in pink", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/beb9fd6d806c96a1fc008977850761f1bc11c0e49fac272a021e11785fba46813bd8f6c66e31d1a6df3853f7cab78b7a242871798b0b94a20a09901925a8e0d5", "height": 737, "url": "https://lh3.googleusercontent.com/Ts8rbXjz2Oemc7LixUJrHcsXICUeM2tuHs89gPRfDL9pIfbLsHdu_5UUBCpx6cM8jkiFn3qAtNj3Ue0cpMTTzTprdhwjw-e45G4JrpaX51p4aHU9iOo", "width": 1080}}], "is_beta": false, "markdown_content": "\n\n# Dialogs\n\n[Dialogs](https://material.io/components/dialogs/) inform users about a task and\ncan contain critical information, require decisions, or involve multiple tasks.\n\n![\"Dialog to confirm settings centered in a screen\"](assets/dialogs/dialogs_hero.png)\n\n**Contents**\n\n* [Using dialogs](#using-dialogs)\n* [Basic dialog](#basic-dialog)\n* [Full-screen dialog](#full-screen-dialog)\n* [Theming](#theming-dialogs)\n\n## Using dialogs\n\nBefore you can use Material dialogs, you need to add a dependency to the\nMaterial Components for Android library. For more information, go to the\n[Getting started](/libraries/mdc-android/getting-started)\npage.\n\n```kt\nMaterialAlertDialogBuilder(context)\n // Add customization options here\n .show()\n```\n\n### Making dialogs accessible\n\nThe contents within a dialog should follow their own accessibility guidelines,\nsuch as an icon on a title having a content description via the\n`android:contentDescription` attribute set in the\n`MaterialAlertDialog.Material3.Title.Icon` style or descendant.\n\n### Types\n\nThere are two types of dialogs: 1\\. [Basic dialog](#basic-dialog), 2\\.\n[Full-screen dialog](#full-screen-dialog)\n\n\n\n## Dialogs\n\nA dialog is a type of modal window that appears in front of app content to\nprovide critical information or ask for a decision. Dialogs disable all app\nfunctionality when they appear, and remain on screen until confirmed, dismissed,\nor a required action has been taken.\n\nDialogs are purposefully interruptive, so they should be used sparingly.\n\n### Dialog examples\n\nAPI and source code:\n\n* `MaterialAlertDialogBuilder`\n * [Class description](https://developer.android.com/reference/com/google/android/material/dialog/MaterialAlertDialogBuilder)\n * [Class source](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/dialog/MaterialAlertDialogBuilder.java)\n\n## Basic dialog\n\nThe following example shows a basic dialog.\n\n\n\nIn code:\n\n```kt\nMaterialAlertDialogBuilder(context)\n .setTitle(resources.getString(R.string.title))\n .setMessage(resources.getString(R.string.supporting_text))\n .setNeutralButton(resources.getString(R.string.cancel)) { dialog, which ->\n // Respond to neutral button press\n }\n .setNegativeButton(resources.getString(R.string.decline)) { dialog, which ->\n // Respond to negative button press\n }\n .setPositiveButton(resources.getString(R.string.accept)) { dialog, which ->\n // Respond to positive button press\n }\n .show()\n```\n\n## Full-screen dialog\n\nFull-screen dialogs group a series of tasks, such as creating a calendar entry\nwith the event title, date, location, and time. Because they take up the entire\nscreen, full-screen dialogs are the only dialogs over which other dialogs can\nappear.\n\nThere is no specific Material implementation of a full-screen dialog. You can\nimplement it by using a\n[`DialogFragment`](https://developer.android.com/reference/androidx/fragment/app/DialogFragment)\nas explained in the\n[Android Developer guides](https://developer.android.com/guide/topics/ui/dialogs#FullscreenDialog).\n\n### Anatomy and key properties\n\nA dialog has a container, content (either supporting text or a set of items of a\nparticular type), a background scrim, and, optionally, title and buttons.\n\n\n\n1. Container\n2. Icon (optional)\n3. Title (optional)\n4. Content\n5. Buttons (optional)\n6. Scrim\n\n#### Container attributes\n\nElement | **Attribute** | **Related methods** | **Default value**\n----------------------------------- | -------------------------------------------------------- | ------------------------------------------------------ | -----------------\n**Color** | N/A | N/A | `?attr/colorSurface`\n**Shape** | `app:shapeAppearance`
`app:shapeAppearanceOverlay` | N/A | `?attr/shapeAppearanceMediumComponent` with a corner size of `28dp`\n**Background inset start and end** | `app:backgroundInsetStart`
`app:backgroundInsetEnd` | `setBackgroundInsetStart`
`setBackgroundInsetEnd` | `24dp`\n**Background inset top and bottom** | `app:backgroundInsetTop`
`app:backgroundInsetBottom` | `setBackgroundInsetTop`
`setBackgroundInsetBottom` | `80dp`\n\n#### Title attributes\n\nElement | **Attribute** | **Related methods** | **Default value**\n-------------- | ------------------------ | -------------------------------- | -----------------\n**Text label** | N/A | `setTitle`
`setCustomTitle` | `null`\n**Text color** | `android:textColor` | N/A | `?attr/colorOnSurface`\n**Typography** | `android:textAppearance` | N/A | `?attr/textAppearanceHeadlineSmall`\n**Icon** | N/A | `setIcon`
`setIconAttribute` | `null`\n**Icon tint** | `app:tint` | N/A | `?attr/colorSecondary`\n\n#### Content attributes\n\n**Supporting text**\n\nElement | **Attribute** | **Related methods** | **Default value**\n-------------- | ------------------------ | ------------------- | -----------------\n**Text** | N/A | `setMessage` | `null`\n**Color** | `android:textColor` | N/A | `?attr/colorOnSurfaceVariant`\n**Typography** | `android:textAppearance` | N/A | `?attr/textAppearanceBodyMedium`\n\n**List item**\n\nElement | **Attribute** | **Related methods** | **Default value**\n--------------------------------------- | ------------------------------ | ---------------------- | -----------------\n**List item layout** | `app:listItemLayout` | `setItems` | [`@layout/mtrl_alert_select_dialog_item`](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/dialog/res/layout/mtrl_alert_select_dialog_item.xml)\n**List item layout style** | N/A | N/A | `?attr/materialAlertDialogBodyTextStyle`\n**List item text color** | `android:textColor` | N/A | `?attr/colorOnSurfaceVariant`\n**List item typography** | `android:textAppearance` | N/A | `?attr/textAppearanceBodyMedium`\n**Multi choice item layout** | `app:multiChoiceItemLayout` | `setMultiChoiceItems` | [`@layout/mtrl_alert_select_dialog_multichoice`](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/dialog/res/layout/mtrl_alert_select_dialog_multichoice.xml)\n**Single choice item layout** | `app:singleChoiceItemLayout` | `setSingleChoiceItems` | [`@layout/mtrl_alert_select_dialog_singlechoice`](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/dialog/res/layout/mtrl_alert_select_dialog_singlechoice.xml)\n**Multi/single choice item style** | `android:checkedTextViewStyle` | N/A | `@style/Widget.Material3.CheckedTextView`\n**Multi/single choice item text color** | `android:textColor` | N/A | `?attr/colorOnSurfaceVariant`\n**Multi/single choice item typography** | `android:textAppearance` | N/A | `?attr/textAppearanceBodyLarge`\n\n**Note:** You can set any custom view to be the content of your dialog via the\n`setView` method.\n\n#### Buttons attributes\n\nElement | **Attribute** | **Related methods** | **Default value**\n------------------------------------------------ | --------------------------------- | ------------------- | -----------------\n**Buttons theme attributes (negative/positive)** | `app:buttonBar*ButtonStyle` | N/A | `@style/Widget.Material3.Button.TextButton.Dialog`\n**Buttons theme attributes (neutral)** | `app:buttonBarNeutralButtonStyle` | N/A | `@style/Widget.Material3.Button.TextButton.Dialog.Flush`\n**Buttons (neutral/negative/positive)** | N/A | `set*Button` | `null`\n**Icons** | N/A | `set*ButtonIcon` | `null`\n\nFor specific button attributes, see the\n[Buttons documentation](https://github.com/material-components/material-components-android/tree/master/docs/components/Button.md).\n\n#### Scrim attributes\n\nElement | **Attribute** | **Related methods** | **Default value**\n-------------- | ----------------------------- | ------------------- | -----------------\n**Dim amount** | `android:backgroundDimAmount` | N/A | 32%\n\n#### Theme overlays\n\nElement | **Theme overlay**\n-------------------------- | -----------------\n**Default theme overlay** | `ThemeOverlay.Material3.MaterialAlertDialog`\n**Centered theme overlay** | `ThemeOverlay.Material3.MaterialAlertDialog.Centered`\n\nDefault theme overlay attribute: `?attr/materialAlertDialogTheme`\n\n#### Theme attributes\n\nElement | **Theme attribute** | **Default value**\n------------------------- | ----------------------------------------- | -----------------\n**Default style** | `?attr/alertDialogStyle` | `@style/MaterialAlertDialog.Material3`\n**Title text style** | `?attr/materialAlertDialogTitleTextStyle` | `@style/MaterialAlertDialog.Material3.Title.Text`\n**Supporting text style** | `?attr/materialAlertDialogBodyTextStyle` | `@style/MaterialAlertDialog.Material3.Body.Text`\n\nSee full list of\n[styles](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/dialog/res/values/styles.xml),\n[attributes](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/dialog/res/values/attrs.xml),\nand\n[theme overlays](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/dialog/res/values/themes.xml).\n\n## Theming dialogs\n\nA dialog supports\n[Material Theming](https://material.io/components/dialogs/#theming) which can\ncustomize color, typography and shape.\n\n### Dialog theming example\n\nAPI and source code:\n\n* `MaterialAlertDialogBuilder`\n * [Class description](https://developer.android.com/reference/com/google/android/material/dialog/MaterialAlertDialogBuilder)\n * [Class source](https://github.com/material-components/material-components-android/tree/master/lib/java/com/google/android/material/dialog/MaterialAlertDialogBuilder.java)\n\nThe following example shows a dialog with Material Theming.\n\n\n\n#### Implementing dialog theming\n\nSetting the theme attribute `materialAlertDialogTheme` to your custom\n`ThemeOverlay` will affect all dialogs.\n\nIn `res/values/themes.xml`:\n\n```xml\n\n\n\n```\n\nIn `res/values/styles.xml`:\n\n```xml\n\n\n\n\n \n\n\n```\n\nOr if you want to change only one specific dialog, pass the `themeResId` to the\nconstructor:\n\n```kt\nMaterialAlertDialogBuilder(context, R.style.ThemeOverlay_App_MaterialAlertDialog)\n ...\n .show()\n```\n", "markdown_path": "docs/components/Dialog.md", "meta": {"keywords": null, "meta_robots": " ", "schema_structure": "{}", "share_description": null, "share_image": null, "share_title": null}, "title": "Dialogs", "up_next_section": {"items": []}, "document_id": "4969122496249856"}, "mdc_flutter": {"banners": [{"description": "

Dialogs are available in the Flutter Material library using the useMaterial3 flag. For details on how to implement, visit the API documentation.

", "links": [{"aria_label": "Read about implementing Material Design 3 Dialog using Flutter", "href": "https://api.flutter.dev/flutter/material/Dialog-class.html", "label": "Dialog API reference", "open_new_window": true}, {"aria_label": "Read about implementing Material Design 3 AlertDialog using Flutter", "href": "https://api.flutter.dev/flutter/material/AlertDialog-class.html", "label": "AlertDialog API reference", "open_new_window": true}], "title": "Flutter support available"}], "hide_generic_not_available_message": true, "hide_intro_block": true, "image": [], "is_beta": false, "markdown_content": null, "markdown_path": null, "meta": {"keywords": null, "meta_robots": " ", "schema_structure": "{}", "share_description": null, "share_image": {}, "share_title": null}, "title": "Dialogs", "up_next_section": {"items": []}, "document_id": "5240559261974528"}, "mdc_web": {}, "metadata": {"keywords": "Material, Material Design, Google Material, Google, Material Design 3, Material 3, Material You, Material Design Components, Material Components, components, dialogs", "meta_robots": null, "schema_structure": "{}", "share_description": "Dialogs provide important prompts in a user flow. Use dialogs to make sure users act on information", "share_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/645047f1a7e12769b092d3f1746d0a86fc87b52d90a065f64ae8582a5547fa67706ae0c2b4ca6193b7df3172692ae586d3368247bcdb349749ac862e2e7c6bfb", "height": 560, "url": "https://lh3.googleusercontent.com/mfskU6xDOqSsTCcgnGcsTfdCcT35ftTpCJmb4kuchYadG_41OF410XdGQP08L3iqFsq_pwIJ7_Zyf-8e-8J7nEwdNWMB-PqZGdICdnLsmHYfXy08Wrw", "width": 1120}, "share_title": "Dialogs \u2013 Material Design 3"}, "page_hero": {"foreground_image_1x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/c26a0789742bad25a27d711dea8e655b1048515c311e47467b70ce19aada489175aeebb7b779c1dadfe85d429aec3c13715cea872735e5b59951114d5a593bc2", "height": 1120, "url": "https://lh3.googleusercontent.com/q-dfTEhPWF_-_eM4VpJrZA694bAqAO8BJMQMvVrDsqZ1kHyCOOhhnaXWYJw-wP3bGv6CXAKaEOrAxk2OEY47frCMhjGgp4ZZUtFsUCeMn0dthK0T8w", "width": 1120}, "foreground_image_1x1_dark_mode": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/9ed9fa99e47af01d81ae06cb058b307f64fe5ad80f518c46655fab1b66a8eb019aca19b27aaf200320caaaced0560bc0e68888d728194894bdd9710eb1080594", "height": 1120, "url": "https://lh3.googleusercontent.com/NFxYirrIZx8x5PbwPfor383cpP0mjHhJtIeXy-fAbo878fU2IJ5NEiRubwH3xHFVuN8J12QtUD6erRN6YCyx7e1OluYRzs8hYtRFer3K5oNn562hrOo", "width": 1120}, "foreground_image_2x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/16ec9ceebf3552311c38177ae3e547df730ad4398714a450983194b622471a59168f0d13e76f271096b27d3e5cf3d309a08de27408b08466dfad4e022ecd995c", "height": 1000, "url": "https://lh3.googleusercontent.com/euzNp7-lR2RlNLgwaPFT0mjcpki6eFepE5yrYjxsGmVn3Mi-3e7nmGiVWBdPKUYHKn9cWZncX_09QVYT_0QWdl8vbo88ajt3Usbeqv-_ypmB0pu43-9x", "width": 2000}, "foreground_image_2x1_dark_mode": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/22d90bf7a02bea312349422e7bc3a1a2b915f0a07b4d25e51fc5bef75cd7a3d2cbfe49fe6851f048ad712861e2ac09844daab502007b649de1103fde29ed466c", "height": 1000, "url": "https://lh3.googleusercontent.com/QfWucnJId1YNG8tBqQdCxL5aDcK5kcDxCo57DWf_e9_Q8y-W1Om1ulxFtF0WZI6V4W-bBxj7spcHt2vJx1LfnWFE_PMmQ-aRYS-kH2COxgwyAgmWJg8", "width": 2000}, "full_width": false, "full_width_content_centered": false, "horizontal_foreground_alignment_override": null, "main_background_image": {}, "main_background_image_dark_mode": {}, "split_background_asset": {}, "split_background_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/06a3ad2112a7a75e2a38793327f4e8e486d6c9419a6767c779d54d7dbbacfbc5490ff86f55d08ab821aa6e89881362465f8332e9bf5eb67b9c7c9bba8b59c6d6", "height": 4096, "url": "https://lh3.googleusercontent.com/ClaZSxMwSB7FD1hlooJt1hkkA2NiWXWIVrCHb1qizShsE2nNht2euUQm6BmS-C6B6nwzB8DUuuUjYy25E9weS5oMxQFkrWgcucbDtXtBobfs_riZootR", "width": 4096}, "split_background_image_dark_mode": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/04794fbf602312f722329bc76020840bdd22adb6c751aba94802088e571ebaff854cfb859c1cf2efc541f99ed8d274386b67b60c8ad4586e3d9a6079a1af0404", "height": 4096, "url": "https://lh3.googleusercontent.com/hORpCLy8XRY7280TZFng7WkrjURav7C3AApKqntCFUZnFZ4_NybzinpEA-HSGL46pPn_m96V4GL9t08BxTkkKmT3FX3SrRsUi2yWdQyzwQmUqzNSSMj7", "width": 4096}, "vertical_foreground_alignment_override": null}, "title": "Dialogs", "document_id": "5732087063379968", "modified_date": "2025-05-01"} \ No newline at end of file diff --git a/evidence/surface/fetch/layout-applying.json b/evidence/surface/fetch/layout-applying.json new file mode 100644 index 0000000..61b87c6 --- /dev/null +++ b/evidence/surface/fetch/layout-applying.json @@ -0,0 +1 @@ +{"category": null, "component_image_1x1": {}, "component_image_2x1": {}, "component_image_3x1": {}, "component_image_3x2": {}, "component_image_position": "center", "dark_component_image_1x1": {}, "dark_component_image_2x1": {}, "dark_component_image_3x1": {}, "dark_component_image_3x2": {}, "description": "Use window size classes to create layouts that scale across most devices and form factors.", "hero": {"background_asset": {}, "background_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_be877ab23edb4a39be610a57a771c69d_/2a62e31378857d6697036e05f7be96efbf388211a4d46eeefaecbe993b06d74be5826e0e9dcc08fc8b450c5b0faef9f061aebc8cce918d965b2aa7338b8833a6", "height": 1200, "url": "https://lh3.googleusercontent.com/CIyB5lCif4nv2PAOf5rXTP3F5QR1FrtGwOfF0nTi1YUu38kd9Q-16uQ4huiiaPFftqAgZqNM8JaymDXcxUb_P_4vASsP8hlP6jMDXXfezufgTRkMC9s", "width": 2400}, "foreground_image": {}, "foreground_image_alignment": "top", "foreground_image_svg": null, "foreground_text_light": false}, "ia_title": "Foundations / Layout / Applying layout", "isDisplayed": true, "jetpack": {}, "mdc_android": {}, "mdc_flutter": {}, "mdc_web": {}, "metadata": {"keywords": "Material, Material Design, Google Material, Google, Material Design 3, Material 3, Material You, adaptive, responsive, layouts", "meta_robots": " ", "schema_structure": "{}", "share_description": "Use window size classes to create layouts that scale across most devices and form factors.", "share_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_be877ab23edb4a39be610a57a771c69d_/a5205fc1d66462945233e8abb82dd1a98633141a775cf9809bdfc8a8dbcd6bc2215cfc3309a640dceb773eca70977b133e878cd2e6b98ade4a377686ef149260", "height": 540, "url": "https://lh3.googleusercontent.com/9IC8KyAT2SkaRgd0oRs6Q9_EThFKVJQomkWlNMfvupqnnafcaoMNDpju-OSuXaKR5pvAwLF1NGQibVeKPdmwuisCTerH_NdiJdpxYi2AmD9QT_Nleg", "width": 960}, "share_title": "Applying layout \u2013 Material Design 3"}, "page_hero": {"foreground_image_1x1": {}, "foreground_image_1x1_dark_mode": {}, "foreground_image_2x1": {}, "foreground_image_2x1_dark_mode": {}, "full_width": false, "full_width_content_centered": false, "horizontal_foreground_alignment_override": null, "main_background_image": {}, "main_background_image_dark_mode": {}, "split_background_asset": {"file_name": "Google_Mio_UnderstandingLayout_1080x1080.mp4", "file_path": "f3eb2abc4e890d68862f4d078fb6c1b39232bc11ccf12df23c1b7dd35eb6b7554fd0c937fd186d6340d5147c096764df5a2075bff03d3b64333cd60d0efd067a", "url": "https://kstatic.googleusercontent.com/files/f3eb2abc4e890d68862f4d078fb6c1b39232bc11ccf12df23c1b7dd35eb6b7554fd0c937fd186d6340d5147c096764df5a2075bff03d3b64333cd60d0efd067a"}, "split_background_image": {}, "split_background_image_dark_mode": {}, "vertical_foreground_alignment_override": null}, "title": "Applying layout", "document_id": "6396979944620032", "modified_date": "2024-05-10"} \ No newline at end of file diff --git a/evidence/surface/fetch/manifest.yaml b/evidence/surface/fetch/manifest.yaml new file mode 100644 index 0000000..a44b7e4 --- /dev/null +++ b/evidence/surface/fetch/manifest.yaml @@ -0,0 +1,17 @@ +schema_version: 1 +package: "surface_fetch_baseline" +updated_at: "2026-02-23" +source_run: "EXP-2026-02-22-longrun-001" +source_path: "drafts/experiment/runs/EXP-2026-02-22-longrun-001/fetch" +notes: + - "This fetch set was selected as the canonical preserved baseline." + - "Equivalent fetch sets existed in other drafts runs and were byte-identical." +files: + - "bottom-sheets.json" + - "dialogs.json" + - "layout-applying.json" + - "menus.json" + - "motion-easing-duration.json" + - "motion-transitions.json" + - "navigation-drawer.json" + - "side-sheets.json" diff --git a/evidence/surface/fetch/menus.json b/evidence/surface/fetch/menus.json new file mode 100644 index 0000000..ad78789 --- /dev/null +++ b/evidence/surface/fetch/menus.json @@ -0,0 +1 @@ +{"category": "selection", "component_image_1x1": {}, "component_image_2x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/f3d1d05fa3f98b7ed34577f6bf887203bd162e7b9345e649ff4cbf5fed67d6f6ab856ea9ccd72f468d1470880f1d9dc00bbeb72b9846dbb421bcda0fbc41139c", "height": 560, "url": "https://lh3.googleusercontent.com/k9FOfw5GEcyNWd-Iqc8zhd2qfWULlhXjaEnV_N_twzxIYtR77YiLgLEwGgcK-xdWA4uumfdX-aYKo_V8-Ufqarw-n2YmJqeNiyDcNEqza48A7eipbOg", "width": 1120}, "component_image_3x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/205babd7b20c05185e7b46ac4fae5fb4d132f7ff66544af9160f3f36e424b640f0b0791be257bd8b2e8554e4af9cfc2312ca19b10da7f238ba6c331e917af67a", "height": 560, "url": "https://lh3.googleusercontent.com/xtpBXljEGhJN5JEnvNw2UVR_l_yng6IfOoCQGC6Ls4WxVvoROmN5O8qyvdCoIelP90wzZR3_QN41CDXlHgqZnw5_WjWIs9uPP8iI0K90lFQ_HsCLyh0", "width": 1680}, "component_image_3x2": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/3e17b5ed53619638fe6ece233a0a31848381cf699b3685def701b7d49f8f9e5eb0561a077a3ca478c95d76ef770e8c5f80478a0a49b94522078eb5cf3c481fa5", "height": 560, "url": "https://lh3.googleusercontent.com/VxcZwSoYqM_KdhpHLPeAaM-KScIOFBt4uzxbifNaVrQwQYEw6ZbDh-ehhaBrd_lxWn5mTxUXUKb_foMmV-zYN-rd4vJOEl6cxWdZToarE8edli0mhp4", "width": 840}, "component_image_position": "center", "dark_component_image_1x1": {}, "dark_component_image_2x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/f46cfd9b67bc7fcda8898b95c5f15a6642f8d0227aceb890cb46d730bd4b57f3549972d614c76bfdc1a7ab9b5af623eb8ba675c431f01f1a6c28851962f30c48", "height": 560, "url": "https://lh3.googleusercontent.com/g-nOrn0GXf-nZJ22TwAM8ChICae6oDtnAVPPWnoe0JQ9Mh2OpWkl_qfQ45Q59YUZDv2ZCLnpierG13fE8UinLDi1xFNv1XyyYmiw0vf0wfqEjvadTJI", "width": 1120}, "dark_component_image_3x1": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/89bb3e1f2cd4b1ca5e62e21f9042322a871a424e12dcb6c5b46d1fe57606f5283f9f5b73b9ecd354963a903739e7212af3fb8b950d30c5fbec2975419f26085e", "height": 560, "url": "https://lh3.googleusercontent.com/8qsTRu46sT7aR_EHcDRrbgTHAp_emItoya46tNkjc8u0i5UHbeo3u5kViYcYSXq8v0UeGyFXJeCDyJ8Nm4dqxpMc-gkASRqZDIa94rTnpXe5lCFBFNzQ", "width": 1680}, "dark_component_image_3x2": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/aa0f1e028214700f1e8d3cccf8824090f78360e951eabac3341cd71dbb64778c761d6a2b42f075a87360a70de9a2aaa95682a11bdac4c679a0d2004cd2547daf", "height": 560, "url": "https://lh3.googleusercontent.com/4un3gOBZ2kAujd4tLWLaKBpPgfMyz0TEQzAcdQqRTj0KFe-snSi9L7Sqpe0rdFwruDkRvn4wlQmtqJPNY54F1fQeKkXuGlFHyKjLWB2JsdpAuPIyNN4", "width": 840}, "description": null, "hero": {"background_asset": {}, "background_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/7a8fa848b6b77f42495f75eafac551a09a53d560ca7a9430c7a5056f89ba232f0bb1fd6ef8b4888e8f33871e050a153c2d8eb677e4a8c45a68775a42c305784d", "height": 4096, "url": "https://lh3.googleusercontent.com/2fY0UpxDH_6NCU8n8L7xSnxSokFQqdA8qOWo_NNCU8ikQW5BhiP4Ha5aP_L0oYnFC8wtHdkEtxxC8CcGue6Tj_wrY7-QtEVsYjZziIasgck97JD4pIo", "width": 4096}, "foreground_image": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_95f9980dd12d4f8886c881994eaa6a79_/ec9075bd851c86a3ae9930b62d7fefbc2cc4300973d11813ae6c8f7cbc2da2b00341136ab501bac4e3a8e8e85c144ced8f574fc89a1f0c25afa66c66b9f54cb1", "height": 1120, "url": "https://lh3.googleusercontent.com/1nhfDYUuWn2Y837J6niWOD9td-3BcYN1e83LtvwoWVyMrs16SQcxxWnr1dlxrQPyyTPe08aKzSlxetsVLWX1uBTyvdYRthlhgNrv5Q8UnxJGGbj1Ow", "width": 1120}, "foreground_image_alignment": "top", "foreground_image_svg": null, "foreground_text_light": true}, "ia_title": "Components / Menus", "isDisplayed": true, "jetpack": {}, "mdc_android": {"banners": [{"description": "

Menus are available in the Material Design 3 library for Jetpack Compose. For implementation details, visit the androidx.compose.material3 library documentation on Android Developers:

", "links": [{"aria_label": "Read about implementing Material Design 3 AlertDialogs using Jetpack Compose", "href": "https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#DropdownMenu(kotlin.Boolean,kotlin.Function0,androidx.compose.ui.Modifier,androidx.compose.ui.unit.DpOffset,androidx.compose.ui.window.PopupProperties,kotlin.Function1)", "label": "DropdownMenu API Reference", "open_new_window": true}, {"aria_label": "Read about implementing Material Design 3 ExposedDropdownMenu using Jetpack Compose", "href": "https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#ExposedDropdownMenuBox(kotlin.Boolean,kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Function1)", "label": "ExposedDropdownMenu API Reference", "open_new_window": true}], "title": "Jetpack Compose support available"}], "hide_generic_not_available_message": true, "hide_intro_block": true, "image": [{"a11y_description": "Menu hero example", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/da9bc72e7ce22d5466d4932275b9af1a2c798a87b47943fc3c8be1e70e12255833c07378041e1cd278e80620c1076ce4929d72b2b37ccb282d449b6ab54f7020", "height": 754, "url": "https://lh3.googleusercontent.com/Kb7vuTWlHrnwuv-8taEKtQZuTzKta54HxDNsUzN864yfInGGIx333w1VErFA8zlmFEW8hRa9JDXi-K6wUy-ReZQHERNZH4CA273evW3urLrys-KsYQ", "width": 1999}}, {"a11y_description": "Composite image of example dropdown and exposed dropdown menu types", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/13e3ee536262916e9edd4ea67762d836c54d571e515ce1234308e96abcede4082375a2aec11cc9c6b82050663ed69a456182b3ff18cc83ad7da8eb17fd944ab5", "height": 940, "url": "https://lh3.googleusercontent.com/X_D_If_smcA-GPD4A-USkWJgj0UgT9YSx2JmTcxI7dfvJiKU7M08lpFsc8-MNb-ZHuh5jVwpx5b6eeMuzlpNVmt0F6MbSVjvL63oGlzeJLFEjBarQeQ", "width": 2048}}, {"a11y_description": "Composite image: purple \"Menus\" bar, and purple \"Menus\" bar with white menu\nand 3 options", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/d5dadc3e2f0d1b341fb784613fcb87fde6231cc9803588b5b56f06415e5106f66cae8dba24edb84ee928dd94aea9f6f1a78e67b9fefa89bf09a46e1fa969c64c", "height": 650, "url": "https://lh3.googleusercontent.com/gh99kXdWMairaR0nsNpVkgE90_dMb74HMzoPfxFIZwIJo2vkJ3wKL8Nd87QdKXqaA1zFImD3ACa0sUbuqpQ1WYU5QapTEB8JOuO18JZLERWbE_d_n9c", "width": 1080}}, {"a11y_description": "White menu window with two options floating over a white background with grey\ntext", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/c308438bedef64d236cf034176a68e6bd5c4758eeb0a6e9e91c4762dfd5da830ac7f340ada2280d0210d57412bf6b0b1886a4e2f05b65e05598079b083683520", "height": 463, "url": "https://lh3.googleusercontent.com/j4Crs8v0RxV8F4W40NQ78y8IANvZfG4ra-hAXxP3qe5WVYycY91eVpyfEQbNZ9vX11qryv-lEK0GO1DoI6laBsoOkxjBHpDGH_Gy0lWx2gzZBVfrjA", "width": 1080}}, {"a11y_description": "White menu container with 3 options open below a purple \"Show menu\" button", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/3a9ece693f83eb50c0810d14bc629f88293aa830fe94487f17bbe440a9a7a3b59cd558f6819e2fb6e38842aca960d9eb7e53b0da8698c6166cee415efb52b071", "height": 655, "url": "https://lh3.googleusercontent.com/n--GNHXqdi6IAWHN4AqGWHN8hWZ1D2Zp2Vzz4H5Hyn13DCUjQtJYxTg8QEA3KVNSJ_32X4SPuwRJrMHveZwvqwxBaKXptGRO3OwUamedoPtLMiVW3nw", "width": 1080}}, {"a11y_description": "White menu window with 3 options open below a purple \"Show menu\" button. Each\noption has an icon.", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/243744613f0f55c96c0f809d11beb9598662c3525f4942dfe6c5bf5981be692b55bc48d382d1f6814dff115788a6e93392e5870eb6f6df474faa23f49126c677", "height": 746, "url": "https://lh3.googleusercontent.com/DQC8wOdRISEh_rVPd8uCJryHd1SvjzYkKBSiSkCd6-9Zm1Cpwafo5cRWWqh6xfhby-6xgKNazfOnjDRy0zNkBNYtUkUJ8xMNs_05Cr-bV8QsJ96Zhrkb", "width": 1080}}, {"a11y_description": "White menu container with 3 options below a purple \"Show menu\" button", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/7f7449899e6653b6854d950c1ec540034cb2dbfdccbd5f150950d2cd1bfc2d6065595a02bd2d1a855dd2b24f3c12bcfbddcff952dd54e98cc9419e59b4d9ef69", "height": 851, "url": "https://lh3.googleusercontent.com/dUS2p3rCcRYLLAqbINsDJNG6BS35MCimP6RCHjoEPybt7rCmylcw6umvXSxSbZLPM1_Nw-baGHJxQTRGYoHlevsSK2jvvI2Qmf4BMHyRjyiEcfA2krE", "width": 1080}}, {"a11y_description": "Menus anatomy diagrams", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/3fdf3f958e1e19bdd6e730ab63e3bdea088c222eb1f7ccb8eb5dfc50e30aa17285220a071ded0ee7a7b3cc1404cce78a3ce71194f354b02952bac496eecea132", "height": 1045, "url": "https://lh3.googleusercontent.com/laHuXPSZTM4O_vqnZXda7hziGjy9ASSB-iISC1EyQ3d_7SXZNLp0JXMi795PoYeXC0YO1dfEtUcPh2uNAmXFvLUkW9hwxQqERmpKwN7OqF8GM8OfIx3O", "width": 1999}}, {"a11y_description": "2 menu states with text field element: 1) has \"item 1\", 2) has \"item 1\" and a\n4-item menu container.", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/fc19c6475eac357ad6f5095c449c90472faceca82417de3dc23a06ace9264596e4ccacff8d9f6b004cba367cdc43daedf1b1cf6b367962433cb9a2ddd49a96d9", "height": 1088, "url": "https://lh3.googleusercontent.com/wAY6SANH7FSIDjD3unZacLO13lhDGveMKaa0SPRYxXSb2DYIRMlGsZ5_439T_YMs9oCv47QCPxqlSz5avTVR9XfI9Ln_o1OcHxWMuYwyVMl_CgKmiZQ", "width": 1080}}, {"a11y_description": "Menu with purple outlined text field element and 4 items. Item 1 selected.", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/24ebf0ae74d894bdbd3a3866451ee6b4ca89aae5d40a6dff81bc98ca61036daffd5ccc7ae5927fe5170b1bf157955bada93c8a723da4f4c4f1c070f3ae2ed8b5", "height": 806, "url": "https://lh3.googleusercontent.com/50YgYjtYrYt-pt2z0cbeScd46k87_auB_rG8WzXaI-XH6dsdrn1CrI2C6uy9Peff1MxlyO5EEpOU3x2Jvjzb0DJq844Tg34zCimJruqLXIe-jsCQww", "width": 1080}}, {"a11y_description": "Exposed dropdown menu anatomy diagram", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/899b0462ee201be46c303101b746518dcabc60bd3bcf192fc713d74b7f9ac6c47612039c93bbfce86c3c3891ec71d26316d1428db98df0998f3ebc1c1c6442d9", "height": 1070, "url": "https://lh3.googleusercontent.com/-UN5DIcokiLQt7ga1W60Cgn7jOvXipS0dEOoHTIyj_AAs9PvkqbXZZpeHaXHib0jbBoK3nlFNVBRBjQsRCYnqWxkmICedLpAA3EbjvXBYc-34INabOc", "width": 2048}}, {"a11y_description": "White menu container with brown text showing 3 options", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/1764ef3c118e224429b07bf02f93685a5cc7519afda3744d917a3133226e0aeeadef6ca81491b417075e05c2dd39fb499b322f52b6c2edeaa92d1d97e711f32e", "height": 294, "url": "https://lh3.googleusercontent.com/D12ZcYdcTC8C6Opc_SwZbJ5hA5rCeVZo8hkTEtuawp0WZZ8P3bSSpbGWuar3_ehXTGwLI5AE2GVbixeehq3g2CanaIy7SWB5ZWx7awEkmSaD9c38XK4", "width": 400}}, {"a11y_description": "Composite image of \"Show menu\" button and options: gray button with pink\nunderline, and white button with pink\noutline", "bordered": true, "caption": null, "case": null, "file": {"file_path": "_f68a99ffe9144467a917320028f1ce3b_/_293ae6e29e4b420a9648cf846508437a_/2f6fe3ba4e94014212938f293059220eb01ad09b5e8d0493f5f62f19eee576ad0f4b91f1418c3e25ec9e8eae775a23fc9b3ed75bb96463bd1099b05602e46156", "height": 363, "url": "https://lh3.googleusercontent.com/MRNcysCTiR5Z9bsP_I3LkcLnvFTCl5ZIWdQrkjfeMUe4-YuhoI8duuHM7syR5q3m3pWUCi-6HI4Zm2ZSBbznP4A3_9Ylvbx8i4BK4uafeet1ClZat29o", "width": 1000}}], "is_beta": false, "markdown_content": "\n\n# Menus\n\n[Menus](https://material.io/components/menus) display a list of choices on\ntemporary surfaces.\n\n\n\n**Contents**\n\n* [Using menus](#using-menus)\n* [Dropdown menus](#dropdown-menus)\n* [Exposed dropdown menus](#exposed-dropdown-menus)\n* [Theming](#theming-menus)\n\n## Using menus\n\nA menu displays a list of choices on a temporary surface. They appear when users\ninteract with a button, action, or other control.\n\nBefore you can use Material menus, you need to add a dependency to the Material\nComponents for Android library. For more information, go to the\n[Getting started](/libraries/mdc-android/getting-started)\npage.\n\nA typical menu resource looks like this:\n\n```xml\n\n\n \n \n \n\n```\n\nA typical exposed dropdown menu looks like this:\n\n```xml\n\n\n \n\n\n```\n\nSee the [dropdown menus](#dropdown-menus) and\n[exposed dropdown menus](#exposed-dropdown-menus) sections for detailed usage\ninformation.\n\n### Making menus accessible\n\nMenus are readable by most screen readers, such as TalkBack. Text rendered in\nmenus is automatically provided to accessibility services. Additional content\nlabels are usually unnecessary.\n\nAndroid's exposed dropdown menu component APIs support both label text and\nhelper text, which tell the user what information is requested for a menu. While\noptional, their use is strongly encouraged. For more information about this\ncomponent's accessibility, check out\n[the text field's a11y section](https://www.github.com/material-components/material-components-android/tree/master/docs/components/TextField.md#making-text-fields-accessible).\n\n### Types\n\nMenus allow users to make a selection from multiple options. They are less\nprominent and take up less space than selection controls, such as a set of radio\nbuttons.\n\nThere are two types of menus: 1\\. [Dropdown menus](#dropdown-menus) (overflow,\ncontext, popup, and list popup window menus), 2\\.\n[Exposed dropdown menus](#exposed-dropdown-menus).\n\n\n\n## Dropdown menus\n\nDropdown menus display a list of options, triggered by an icon, button, or\naction. Their placement varies based on the element that opens them.\n\n### Dropdown menu examples\n\nAPI and source code:\n\n* `Menu`\n * [Class definition](https://developer.android.com/reference/android/view/Menu)\n* `MenuInflater`\n * [Class definition](https://developer.android.com/reference/android/view/MenuInflater)\n* `ContextMenu`\n * [Class definition](https://developer.android.com/reference/android/view/ContextMenu)\n* `PopupMenu`\n * [Class definition](https://developer.android.com/reference/android/widget/PopupMenu)\n* `ListPopupWindow`\n * [Class definition](https://developer.android.com/reference/android/widget/ListPopupWindow)\n\n#### Overflow menus\n\nThe following example shows an overflow menu.\n\n\n\nIn code:\n\n```kt\noverride fun onCreateOptionsMenu(menu: Menu): Boolean {\n val inflater: MenuInflater = menuInflater\n inflater.inflate(R.menu.overflow_menu, menu)\n return true\n}\n```\n\nIn `res/menu/overflow_menu.xml`:\n\n```xml\n\n \n \n \n\n```\n\n#### Context menus\n\nThe following example shows a context menu that appears when a `TextView` is\npressed for a designated amount of time.\n\n\n\nIn code:\n\n```kt\noverride fun onCreate(savedInstanceState: Bundle?) {\n ...\n val contextMenuTextView = view.findViewById(R.id.context_menu_tv)\n // Register context menu for TextView\n registerForContextMenu(contextMenuTextView)\n}\n\noverride fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {\n val contextMenuTextView = v as TextView\n val context = context\n // Add menu items via menu.add\n menu.add(R.string.option_1)\n .setOnMenuItemClickListener { item: MenuItem? ->\n // Respond to item click.\n }\n menu.add(R.string.option_2)\n .setOnMenuItemClickListener { item: MenuItem? ->\n // Respond to item click.\n }\n}\n\noverride fun onContextMenuClosed(menu: Menu) {\n // Respond to context menu being closed.\n}\n```\n\nIn the layout:\n\n```xml\n\n```\n\nAlternatively, you can inflate a context menu in `onCreateContextMenu` (as with\nthe overflow menu):\n\n```kt\noverride fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {\n super.onCreateContextMenu(menu, v, menuInfo)\n val inflater: MenuInflater = menuInflater\n inflater.inflate(R.menu.context_menu, menu)\n}\n\n// Then, to handle clicks:\noverride fun onContextItemSelected(item: MenuItem): Boolean {\n val info = item.menuInfo as AdapterView.AdapterContextMenuInfo\n return when (item.itemId) {\n R.id.option_1 -> {\n // Respond to context menu item 1 click.\n true\n }\n R.id.option_2 -> {\n // Respond to context menu item 2 click.\n true\n }\n else -> super.onContextItemSelected(item)\n }\n}\n```\n\nwith a `res/menu/context_menu.xml`:\n\n```xml\n\n \n \n\n```\n\n#### Popup menus\n\nThe following example shows a popup menu that displays when a button is clicked.\n\n\n\nIn code:\n\n```kt\noverride fun onCreate(savedInstanceState: Bundle?) {\n ...\n val button = view.findViewById + + + ) + + await waitFor(() => { + expect(screen.getByTestId('content-node')).not.toBeNull() + }) + + expect(document.body.querySelector('.m3-dialog__icon')).not.toBeNull() + expect(document.body.querySelector('.m3-dialog__header')).not.toBeNull() + expect(document.body.querySelector('.m3-dialog__footer')).not.toBeNull() + expect(document.body.querySelector('.m3-dialog__content')).not.toBeNull() + expect(screen.getByTestId('icon-node')).not.toBeNull() + expect(screen.getByTestId('header-node')).not.toBeNull() + expect(screen.getByTestId('footer-node')).not.toBeNull() + expect(screen.getByTestId('content-node')).not.toBeNull() + }) + + test('forwards dialog attributes and keeps default modal role', async () => { + render( + + Dialog body + + ) + + await waitFor(() => { + expect(screen.getByTestId('dialog-root')).not.toBeNull() + }) + + const dialog = document.body.querySelector('.m3-surface[role="dialog"]') as HTMLElement | null + + expect(dialog?.getAttribute('aria-label')).toBe('Settings dialog') + expect(dialog?.getAttribute('role')).toBe('dialog') + }) + + test('keeps dialog mounted during close animation before unmounting', async () => { + const { rerender } = render( + + Dialog body + + ) + + await waitFor(() => { + expect(screen.getByText('Dialog body')).not.toBeNull() + }) + + vi.useFakeTimers() + + rerender( + + Dialog body + + ) + + expect(screen.getByText('Dialog body')).not.toBeNull() + + await act(async () => { + vi.advanceTimersByTime(300) + }) + + expect(screen.queryByText('Dialog body')).toBeNull() + }) + + test('toggles scrim visibility when fullscreen changes', async () => { + const { rerender } = render( + + Dialog body + + ) + + await waitFor(() => { + expect(document.body.querySelector('.m3-surface__scrim')).not.toBeNull() + }) + + rerender( + + Dialog body + + ) + + await waitFor(() => { + expect(screen.getByText('Dialog body')).not.toBeNull() + }) + + expect(document.body.querySelector('.m3-surface__scrim')).toBeNull() + }) }) diff --git a/m3-react/tests/M3Popper.e2e.tsx b/m3-react/tests/M3Popper.e2e.tsx index 9516c23..5f789fc 100644 --- a/m3-react/tests/M3Popper.e2e.tsx +++ b/m3-react/tests/M3Popper.e2e.tsx @@ -12,6 +12,8 @@ import { createRef } from 'react' import { M3Popper } from '@/components/popper' +type PopperSide = 'top' | 'bottom' | 'left' | 'right' + const rect = (x: number, y: number, width: number, height: number): DOMRect => ( DOMRect.fromRect({ x, @@ -26,14 +28,59 @@ const expectedX = (popper: HTMLElement, offsetCrossAxis = 0) => { return Math.round(100 + 20 - width / 2 + offsetCrossAxis) } -const expectTransform = (popper: HTMLElement, x: number, y: number) => { - expect(popper.style.transform).toMatch(new RegExp(`^translate3d\\(${x}px,\\s*${y}px,\\s*0px\\)$`)) +const expectTransform = (positioner: HTMLElement, x: number, y: number) => { + expect(positioner.style.transform).toMatch(new RegExp(`^translate3d\\(${x}px,\\s*${y}px,\\s*0px\\)$`)) +} + +const expectAnimationSide = (popper: HTMLElement, side: PopperSide) => { + const expected = { + top: { + originX: 'center', + originY: 'bottom', + enterX: '0px', + enterY: '-2px', + scaleX: '0.995', + scaleY: '0.72', + }, + bottom: { + originX: 'center', + originY: 'top', + enterX: '0px', + enterY: '2px', + scaleX: '0.995', + scaleY: '0.72', + }, + left: { + originX: 'right', + originY: 'center', + enterX: '-2px', + enterY: '0px', + scaleX: '0.72', + scaleY: '0.995', + }, + right: { + originX: 'left', + originY: 'center', + enterX: '2px', + enterY: '0px', + scaleX: '0.72', + scaleY: '0.995', + }, + }[side] + + expect(popper.classList.contains('m3-popper_animated')).toBe(true) + expect(popper.style.getPropertyValue('--m3-popper-origin-x')).toBe(expected.originX) + expect(popper.style.getPropertyValue('--m3-popper-origin-y')).toBe(expected.originY) + expect(popper.style.getPropertyValue('--m3-popper-enter-x')).toBe(expected.enterX) + expect(popper.style.getPropertyValue('--m3-popper-enter-y')).toBe(expected.enterY) + expect(popper.style.getPropertyValue('--m3-popper-scale-x-hidden')).toBe(expected.scaleX) + expect(popper.style.getPropertyValue('--m3-popper-scale-y-hidden')).toBe(expected.scaleY) } -const parseTransform = (popper: HTMLElement) => { - const match = popper.style.transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px,\s*0px\)/) +const parseTransform = (positioner: HTMLElement) => { + const match = positioner.style.transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px,\s*0px\)/) if (!match) { - throw new Error(`Unexpected transform: ${popper.style.transform}`) + throw new Error(`Unexpected transform: ${positioner.style.transform}`) } return { @@ -44,10 +91,14 @@ const parseTransform = (popper: HTMLElement) => { const waitForPopper = async () => { await waitFor(() => { + expect(document.body.querySelector('.m3-popper-positioner')).not.toBeNull() expect(document.body.querySelector('.m3-popper')).not.toBeNull() }) - return document.body.querySelector('.m3-popper') as HTMLElement + return { + positioner: document.body.querySelector('.m3-popper-positioner') as HTMLElement, + popper: document.body.querySelector('.m3-popper') as HTMLElement, + } } describe('m3-react/popper e2e', () => { @@ -86,7 +137,7 @@ describe('m3-react/popper e2e', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { positioner, popper } = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) const x = expectedX(popper) @@ -96,8 +147,13 @@ describe('m3-react/popper e2e', () => { }) await waitFor(() => { - expectTransform(popper, x, 80) - expect(popper.style.position).toBe('absolute') + expectTransform(positioner, x, 80) + expect(positioner.style.position).toBe('absolute') + expect(popper.classList.contains('m3-popper_animated')).toBe(false) + expect(popper.style.getPropertyValue('--m3-popper-enter-x')).toBe('') + expect(popper.style.getPropertyValue('--m3-popper-enter-y')).toBe('') + expect(popper.style.getPropertyValue('--m3-popper-scale-x-hidden')).toBe('') + expect(popper.style.getPropertyValue('--m3-popper-scale-y-hidden')).toBe('') }) }) @@ -125,7 +181,7 @@ describe('m3-react/popper e2e', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { positioner, popper } = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) const x = expectedX(popper, 7) @@ -135,8 +191,8 @@ describe('m3-react/popper e2e', () => { }) await waitFor(() => { - expectTransform(popper, x, 70) - expect(popper.style.position).toBe('absolute') + expectTransform(positioner, x, 70) + expect(positioner.style.position).toBe('absolute') }) }) @@ -155,6 +211,7 @@ describe('m3-react/popper e2e', () => { overflow={[]} offsetMainAxis={0} offsetCrossAxis={0} + animated={true} detachTimeout={null} >
@@ -164,7 +221,7 @@ describe('m3-react/popper e2e', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { positioner, popper } = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) @@ -175,9 +232,10 @@ describe('m3-react/popper e2e', () => { let bottomX = 0 let bottomY = 0 await waitFor(() => { - const point = parseTransform(popper) + const point = parseTransform(positioner) bottomX = point.x bottomY = point.y + expectAnimationSide(popper, 'bottom') }) mounted.rerender( @@ -189,6 +247,7 @@ describe('m3-react/popper e2e', () => { overflow={[]} offsetMainAxis={0} offsetCrossAxis={0} + animated={true} detachTimeout={null} >
@@ -197,14 +256,11 @@ describe('m3-react/popper e2e', () => { ) - await act(async () => { - await popperRef.current?.adjust() - }) - await waitFor(() => { - const point = parseTransform(popper) + const point = parseTransform(positioner) expect(point.x).toBeGreaterThan(bottomX) expect(point.y).not.toBe(bottomY) + expectAnimationSide(popper, 'right') }) }) @@ -223,6 +279,7 @@ describe('m3-react/popper e2e', () => { overflow={[]} offsetMainAxis={0} offsetCrossAxis={0} + animated={true} detachTimeout={null} >
@@ -232,7 +289,7 @@ describe('m3-react/popper e2e', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { positioner, popper } = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) @@ -242,7 +299,7 @@ describe('m3-react/popper e2e', () => { let y0 = 0 await waitFor(() => { - y0 = parseTransform(popper).y + y0 = parseTransform(positioner).y }) mounted.rerender( @@ -254,6 +311,38 @@ describe('m3-react/popper e2e', () => { overflow={[]} offsetMainAxis={10} offsetCrossAxis={0} + animated={true} + detachTimeout={null} + > +
+ Popper content +
+ + ) + + await waitFor(() => { + const y1 = parseTransform(positioner).y + expect(Math.round(Math.abs(y1 - y0))).toBe(10) + expectAnimationSide(popper, 'top') + }) + }) + + test('updates animation direction after flip when bottom placement has no space', async () => { + target = document.createElement('button') + document.body.append(target) + + const popperRef = createRef() + + const mounted = render( +
@@ -261,14 +350,55 @@ describe('m3-react/popper e2e', () => {
) + unmount = mounted.unmount + + const { popper } = await waitForPopper() + const y = window.innerHeight - 12 + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, y, 40, 20)) await act(async () => { await popperRef.current?.adjust() }) await waitFor(() => { - const y1 = parseTransform(popper).y - expect(Math.round(Math.abs(y1 - y0))).toBe(10) + expectAnimationSide(popper, 'top') + }) + }) + + test('applies animation vectors for left placement', async () => { + target = document.createElement('button') + document.body.append(target) + + const popperRef = createRef() + + const mounted = render( + +
+ Popper content +
+
+ ) + unmount = mounted.unmount + + const { popper } = await waitForPopper() + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(100, 50, 40, 20)) + + await act(async () => { + await popperRef.current?.adjust() + }) + + await waitFor(() => { + expectAnimationSide(popper, 'left') }) }) }) diff --git a/m3-react/tests/M3Popper.test.tsx b/m3-react/tests/M3Popper.test.tsx index 1eba043..2fc1ca7 100644 --- a/m3-react/tests/M3Popper.test.tsx +++ b/m3-react/tests/M3Popper.test.tsx @@ -22,10 +22,14 @@ const rect = (x: number, y: number, width: number, height: number): DOMRect => ( const waitForPopper = async () => { await waitFor(() => { + expect(document.body.querySelector('.m3-popper-positioner')).not.toBeNull() expect(document.body.querySelector('.m3-popper')).not.toBeNull() }) - return document.body.querySelector('.m3-popper') as HTMLElement + return { + positioner: document.body.querySelector('.m3-popper-positioner') as HTMLElement, + popper: document.body.querySelector('.m3-popper') as HTMLElement, + } } describe('m3-react/popper', () => { @@ -79,7 +83,7 @@ describe('m3-react/popper', () => { fireEvent.focus(target) - const popper = await waitForPopper() + const { popper } = await waitForPopper() await waitFor(() => { expect(popper.classList.contains('m3-popper_shown')).toBe(true) }) @@ -107,7 +111,7 @@ describe('m3-react/popper', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { popper } = await waitForPopper() await waitFor(() => { expect(popper.classList.contains('m3-popper_shown')).toBe(true) }) @@ -142,7 +146,10 @@ describe('m3-react/popper', () => { ) unmount = mounted.unmount - const popper = await waitForPopper() + const { + popper, + positioner, + } = await waitForPopper() vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect(-10000, -10000, 40, 20)) @@ -151,7 +158,7 @@ describe('m3-react/popper', () => { }) await waitFor(() => { - expect(popper.style.position).toBe('absolute') + expect(positioner.style.position).toBe('absolute') expect(popper.classList.contains('m3-popper_shown')).toBe(false) }) }) diff --git a/m3-react/tests/M3SideSheet.test.tsx b/m3-react/tests/M3SideSheet.test.tsx new file mode 100644 index 0000000..7840a6b --- /dev/null +++ b/m3-react/tests/M3SideSheet.test.tsx @@ -0,0 +1,101 @@ +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' + +import { M3Icon } from '@/components/icon' +import { M3SideSheet } from '@/components/side-sheet' + +vi.mock('@/components/scroll-rail', () => ({ + M3ScrollRail: () =>
, +})) + +describe('m3-react/side-sheet', () => { + test('renders dialog and default aria-labelledby', () => { + render( + + + Panel title + + + Panel content + + ) + + const dialog = screen.getByRole('dialog') + const labelId = dialog.getAttribute('aria-labelledby') as string + + expect(screen.getByText('Panel content')).not.toBeNull() + expect(document.getElementById(labelId)?.textContent?.trim()).toBe('Panel title') + }) + + test('keeps title in header and footer outside scroll content', () => { + render( + + + Panel title + + +
Panel content
+ + +
Footer actions
+
+
+ ) + + const title = screen.getByText('Panel title') + const content = screen.getByText('Panel content') + const footer = screen.getByText('Footer actions') + + expect(title.closest('.m3-side-sheet__header')).not.toBeNull() + expect(title.closest('.m3-side-sheet__content')).toBeNull() + expect(content.closest('.m3-side-sheet__content')).not.toBeNull() + expect(footer.closest('.m3-side-sheet__footer')).not.toBeNull() + expect(footer.closest('.m3-side-sheet__content')).toBeNull() + }) + + test('emits close request on scrim click when not docked', async () => { + const onToggle = vi.fn() + + render( + + + Panel title + + + ) + + await waitFor(() => { + expect(document.body.querySelector('.m3-surface__scrim')).not.toBeNull() + }) + + const scrim = document.body.querySelector('.m3-surface__scrim') as HTMLElement + + fireEvent.click(scrim) + + expect(onToggle).toHaveBeenCalledWith(false) + }) + + test('emits close request on close button click', () => { + const onToggle = vi.fn() + + render( + + + Panel title + + + + + + + ) + + fireEvent.click(screen.getByRole('button')) + + expect(onToggle).toHaveBeenCalledWith(false) + }) +}) diff --git a/m3-react/tests/M3Surface.test.tsx b/m3-react/tests/M3Surface.test.tsx new file mode 100644 index 0000000..f888596 --- /dev/null +++ b/m3-react/tests/M3Surface.test.tsx @@ -0,0 +1,112 @@ +import { + fireEvent, + render, + screen, +} from '@testing-library/react' + +import { M3Surface } from '@/components/surface' + +describe('m3-react/surface', () => { + test('applies deterministic default contract', () => { + render( + + Surface body + + ) + + const surface = screen.getByRole('region') + + expect(surface.classList.contains('m3-surface')).toBe(true) + expect(surface.style.width).toBe('100%') + expect(surface.style.height).toBe('100%') + expect(surface.style.margin).toBe('') + expect(surface.style.padding).toBe('') + expect(surface.style.borderTopLeftRadius).toBe('0px') + expect(surface.style.borderTopRightRadius).toBe('0px') + expect(surface.style.borderBottomRightRadius).toBe('0px') + expect(surface.style.borderBottomLeftRadius).toBe('0px') + expect(surface.style.boxSizing).toBe('') + }) + + test('supports global and per-corner rounding overrides', () => { + render( + + ) + + const surface = screen.getByRole('region') + + expect(surface.style.borderTopLeftRadius).toBe('4px') + expect(surface.style.borderTopRightRadius).toBe('16px') + expect(surface.style.borderBottomRightRadius).toBe('24px') + expect(surface.style.borderBottomLeftRadius).toBe('16px') + }) + + test('emits close request and dismiss on scrim click in modal mode', () => { + const onToggle = vi.fn() + const onDismiss = vi.fn() + + render( + + Modal body + + ) + + const scrim = document.body.querySelector('.m3-surface__scrim') as HTMLElement + + fireEvent.click(scrim) + + expect(onToggle).toHaveBeenCalledWith(false) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + test('anchors modal surface to center with fixed positioning', () => { + render( + + ) + + const surface = screen.getByRole('dialog') + + expect(surface.style.position).toBe('fixed') + expect(surface.style.top).toBe('50%') + expect(surface.style.left).toBe('50%') + expect(surface.style.transform).toContain('translate(-50%, -50%)') + expect(surface.style.width).toBe('320px') + expect(surface.style.height).toBe('240px') + }) + + test('maps auto role from elevation and allows explicit variant override', () => { + const { rerender } = render( + + ) + + let surface = screen.getByRole('region') + + expect(surface.classList.contains('m3-surface_container-high')).toBe(true) + + rerender( + + ) + + surface = screen.getByRole('region') + + expect(surface.classList.contains('m3-surface_bright')).toBe(true) + expect(surface.classList.contains('m3-surface_container-high')).toBe(false) + }) +}) diff --git a/m3-react/tests/M3SurfacePanel.test.tsx b/m3-react/tests/M3SurfacePanel.test.tsx new file mode 100644 index 0000000..04d5b92 --- /dev/null +++ b/m3-react/tests/M3SurfacePanel.test.tsx @@ -0,0 +1,61 @@ +import { + render, + screen, +} from '@testing-library/react' + +import { M3SurfacePanel } from '@/components/surface' + +describe('m3-react/surface-panel', () => { + test('renders deterministic static panel contract without implicit landmark role', () => { + render( + + Surface body + + ) + + const surface = screen.getByText('Surface body').closest('.m3-surface') as HTMLElement + + expect(surface).not.toBeNull() + expect(surface.getAttribute('role')).toBeNull() + expect(surface.style.width).toBe('100%') + expect(surface.style.height).toBe('100%') + expect(surface.style.borderTopLeftRadius).toBe('0px') + expect(surface.style.borderBottomLeftRadius).toBe('0px') + }) + + test('supports global and per-corner rounding overrides', () => { + render( + + ) + + const surface = document.querySelector('.m3-surface') as HTMLElement + + expect(surface.style.borderTopLeftRadius).toBe('4px') + expect(surface.style.borderTopRightRadius).toBe('16px') + expect(surface.style.borderBottomRightRadius).toBe('24px') + expect(surface.style.borderBottomLeftRadius).toBe('16px') + }) + + test('maps auto variant from elevation and allows explicit variant override', () => { + const { rerender } = render( + + ) + + let surface = document.querySelector('.m3-surface') as HTMLElement + + expect(surface.classList.contains('m3-surface_container-high')).toBe(true) + + rerender( + + ) + + surface = document.querySelector('.m3-surface') as HTMLElement + + expect(surface.classList.contains('m3-surface_bright')).toBe(true) + expect(surface.classList.contains('m3-surface_container-high')).toBe(false) + }) +}) diff --git a/m3-react/vitest.config.e2e.ts b/m3-react/vitest.config.e2e.ts index 7e60021..ce0869c 100644 --- a/m3-react/vitest.config.e2e.ts +++ b/m3-react/vitest.config.e2e.ts @@ -19,6 +19,7 @@ export default mergeConfig(viteConfig, defineConfig({ test: { name: 'm3-react-e2e', globals: true, + attachmentsDir: join(__artifacts, 'playwright', 'attachments'), include: [ 'tests/**/*.e2e.tsx', ], diff --git a/m3-react/vitest.config.ts b/m3-react/vitest.config.ts index be1257b..12e14df 100644 --- a/m3-react/vitest.config.ts +++ b/m3-react/vitest.config.ts @@ -1,14 +1,21 @@ import { - defineConfig, - mergeConfig, + defineConfig, + mergeConfig, } from 'vitest/config' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + import viteConfig from './vite.config' +const __parent = fileURLToPath(new URL('../', import.meta.url)) +const __artifacts = join(__parent, 'artifacts', 'm3-react') + export default mergeConfig(viteConfig, defineConfig({ - test: { - name: 'm3-react', - globals: true, - environment: 'jsdom', - }, + test: { + name: 'm3-react', + globals: true, + environment: 'jsdom', + attachmentsDir: join(__artifacts, 'vitest', 'attachments'), + }, })) diff --git a/m3-vue/eslint.config.js b/m3-vue/eslint.config.js index de9716d..d1083e4 100644 --- a/m3-vue/eslint.config.js +++ b/m3-vue/eslint.config.js @@ -161,5 +161,19 @@ export default [ 'max-lines-per-function': 'off', }, }, + { + files: [ + '**/*.e2e.ts', + '**/*.e2e.tsx', + '**/*.e2e.test.ts', + '**/*.e2e.test.tsx', + '**/*.smote.ts', + '**/*.smoke.ts', + ], + rules: { + 'max-lines': 'off', + 'max-lines-per-function': 'off', + }, + }, { ignores: ['dist/*'] }, ] diff --git a/m3-vue/package.json b/m3-vue/package.json index 94c7355..d5d62da 100644 --- a/m3-vue/package.json +++ b/m3-vue/package.json @@ -70,6 +70,7 @@ "eslint-plugin-vue": "^10.8.0", "flag-icons": "^7.5.0", "globals": "^17.3.0", + "highlight.js": "^11.11.1", "jsdom": "^28.1.0", "playwright": "^1.55.0", "react": "^18.3.1", diff --git a/m3-vue/src/components/dialog/M3Dialog.vue b/m3-vue/src/components/dialog/M3Dialog.vue index 85225a5..91a938f 100644 --- a/m3-vue/src/components/dialog/M3Dialog.vue +++ b/m3-vue/src/components/dialog/M3Dialog.vue @@ -1,58 +1,87 @@ \ No newline at end of file + +const dialogMounted = ref(props.opened) +const dialogVisible = ref(false) +let enterFrame: number | null = null +let leaveTimer: number | null = null + +const dialogStyle = computed(() => ({ + opacity: dialogVisible.value ? 1 : 0, + transform: props.fullscreen + ? 'translate3d(0, 0, 0)' + : (dialogVisible.value + ? 'translate(-50%, -50%)' + : `translate(-50%, calc(-50% + ${DIALOG_ENTRY_OFFSET_PX}px))`), + transition: `opacity ${DIALOG_TRANSITION_MS}ms ${DIALOG_TRANSITION_TIMING}, transform ${DIALOG_TRANSITION_MS}ms ${DIALOG_TRANSITION_TIMING}`, + pointerEvents: dialogVisible.value ? 'auto' : 'none', +})) + +const clearAnimationHandles = () => { + if (enterFrame !== null) { + window.cancelAnimationFrame(enterFrame) + enterFrame = null + } + + if (leaveTimer !== null) { + window.clearTimeout(leaveTimer) + leaveTimer = null + } +} + +watch(() => props.opened, async (opened) => { + clearAnimationHandles() + + if (opened) { + dialogMounted.value = true + dialogVisible.value = false + await nextTick() + + enterFrame = window.requestAnimationFrame(() => { + enterFrame = null + dialogVisible.value = true + }) + + return + } + + dialogVisible.value = false + + if (!dialogMounted.value) { + return + } + + leaveTimer = window.setTimeout(() => { + leaveTimer = null + dialogMounted.value = false + }, DIALOG_TRANSITION_MS) +}, { + immediate: true, +}) + +onBeforeUnmount(() => { + clearAnimationHandles() +}) + diff --git a/m3-vue/src/components/menu/M3Menu.vue b/m3-vue/src/components/menu/M3Menu.vue index 4e4d1db..62b0df5 100644 --- a/m3-vue/src/components/menu/M3Menu.vue +++ b/m3-vue/src/components/menu/M3Menu.vue @@ -12,6 +12,7 @@ :offset-cross-axis="offsetCrossAxis" :delay="delay" :disabled="disabled" + animated :detach-timeout="detachTimeout" v-bind="$attrs" class="m3-menu" @@ -133,4 +134,4 @@ defineEmits([ 'hidden', 'update:shown', ]) - \ No newline at end of file + diff --git a/m3-vue/src/components/popper/M3Popper.vue b/m3-vue/src/components/popper/M3Popper.vue index edd7e70..77189a5 100644 --- a/m3-vue/src/components/popper/M3Popper.vue +++ b/m3-vue/src/components/popper/M3Popper.vue @@ -4,15 +4,21 @@ :to="container" >
- +
+ +
@@ -155,6 +161,11 @@ const props = defineProps({ default: false, }, + animated: { + type: Boolean, + default: false, + }, + detachTimeout: { type: null as unknown as PropType, validator: Or(isNull, isNumeric), @@ -174,6 +185,7 @@ const emit = defineEmits([ ]) const target = computed(() => typeof props.target === 'function' ? props.target() : props.target?.value) +const positioner = ref(null) const popper = ref(null) const positioning = computed(() => ({ @@ -197,21 +209,75 @@ const state = reactive({ const delay = computed(() => normalizeDelay(props.delay)) +const animationBySide = { + top: { + originX: 'center', + originY: 'bottom', + enterX: '0px', + enterY: '-2px', + scaleX: '0.995', + scaleY: '0.72', + }, + bottom: { + originX: 'center', + originY: 'top', + enterX: '0px', + enterY: '2px', + scaleX: '0.995', + scaleY: '0.72', + }, + left: { + originX: 'right', + originY: 'center', + enterX: '-2px', + enterY: '0px', + scaleX: '0.72', + scaleY: '0.995', + }, + right: { + originX: 'left', + originY: 'center', + enterX: '2px', + enterY: '0px', + scaleX: '0.72', + scaleY: '0.995', + }, +} as const + +const applyAnimationSide = (side: 'top' | 'bottom' | 'left' | 'right') => { + const style = popper.value?.style + if (!style) { + return + } + + const preset = animationBySide[side] + style.setProperty('--m3-popper-origin-x', preset.originX) + style.setProperty('--m3-popper-origin-y', preset.originY) + style.setProperty('--m3-popper-enter-x', preset.enterX) + style.setProperty('--m3-popper-enter-y', preset.enterY) + style.setProperty('--m3-popper-scale-x-hidden', preset.scaleX) + style.setProperty('--m3-popper-scale-y-hidden', preset.scaleY) +} + const adjust = async () => { - if (target.value && popper.value && !state.disposed) { - await computePosition(popper.value, target.value, { + if (target.value && positioner.value && !state.disposed) { + const result = await computePosition(positioner.value, target.value, { ...positioning.value, onReferenceHidden: hide, }) + + if (props.animated) { + applyAnimationSide(result.side) + } } } -const contains = (el: Element | null): boolean => popper.value?.contains(el) ?? false +const contains = (el: Element | null): boolean => positioner.value?.contains(el) ?? false const { autoAdjustOn, autoAdjustOff, -} = useAutoUpdate(target, popper, adjust) +} = useAutoUpdate(target, positioner, adjust) const showingScheduler = new Scheduler() const detachScheduler = new Scheduler() @@ -332,8 +398,8 @@ const initialize = (disposed = false): void => { targetListener.start(target.value, props.targetTriggers) } - if (popper.value) { - popperListener.start(popper.value, props.popperTriggers) + if (positioner.value) { + popperListener.start(positioner.value, props.popperTriggers) } } else { state.disposed = true @@ -400,6 +466,17 @@ watch(() => props.disabled, disabled => { } }) +watch(() => props.animated, animated => { + if (!animated && popper.value) { + popper.value.style.removeProperty('--m3-popper-origin-x') + popper.value.style.removeProperty('--m3-popper-origin-y') + popper.value.style.removeProperty('--m3-popper-enter-x') + popper.value.style.removeProperty('--m3-popper-enter-y') + popper.value.style.removeProperty('--m3-popper-scale-x-hidden') + popper.value.style.removeProperty('--m3-popper-scale-y-hidden') + } +}) + onMounted(() => { globalEvents.on('click', onGlobalClick) globalEvents.on('mousedown', onGlobalMousedown) @@ -425,4 +502,4 @@ onBeforeUnmount(() => { dispose() }) - \ No newline at end of file + diff --git a/m3-vue/src/components/side-sheet/M3SideSheet.vue b/m3-vue/src/components/side-sheet/M3SideSheet.vue index f90d023..9df30e7 100644 --- a/m3-vue/src/components/side-sheet/M3SideSheet.vue +++ b/m3-vue/src/components/side-sheet/M3SideSheet.vue @@ -1,71 +1,75 @@ \ No newline at end of file +const surfaceMounted = ref(props.shown) +const transitionState = ref('idle') +let transitionTimeout: number | null = null + +const surfaceClass = computed(() => ({ + 'm3-side-sheet': true, + 'm3-side-sheet_docked': props.docked, + 'm3-transition-slide-right-enter': transitionState.value === 'pre-enter' || transitionState.value === 'entering', + 'm3-transition-slide-right-enter-active': transitionState.value === 'entering', + 'm3-transition-slide-right-leave-active': transitionState.value === 'pre-exit' || transitionState.value === 'exiting', + 'm3-transition-slide-right-leave-to': transitionState.value === 'exiting', +})) + +const headerClass = computed(() => ({ + 'm3-side-sheet__header': true, + 'm3-side-sheet__header_has-leading-affordance': 'affordance' in slots, +})) + +const surfaceScrimShown = computed(() => !props.docked && props.shown && transitionState.value !== 'pre-enter') + +const surfaceAttrs = computed(() => ({ + ...attrs, + ...('aria-label' in attrs || 'aria-labelledby' in attrs ? {} : { + 'aria-labelledby': _id.value + '-title', + }), + ...('aria-modal' in attrs || !props.docked ? {} : { + 'aria-modal': 'false', + }), +})) + +function clearTransitionTimer() { + if (transitionTimeout === null) { + return + } + + clearTimeout(transitionTimeout) + transitionTimeout = null +} + +async function enterSurface() { + clearTransitionTimer() + surfaceMounted.value = true + transitionState.value = 'pre-enter' + + await nextTick() + await new Promise(resolve => requestAnimationFrame(() => resolve())) + + transitionState.value = 'entering' + transitionTimeout = window.setTimeout(() => { + transitionState.value = 'idle' + transitionTimeout = null + }, SIDE_SHEET_TRANSITION_MS) +} + +async function leaveSurface() { + if (!surfaceMounted.value) { + transitionState.value = 'idle' + return + } + + clearTransitionTimer() + transitionState.value = 'pre-exit' + + await nextTick() + await new Promise(resolve => requestAnimationFrame(() => resolve())) + + transitionState.value = 'exiting' + transitionTimeout = window.setTimeout(() => { + surfaceMounted.value = false + transitionState.value = 'idle' + transitionTimeout = null + }, SIDE_SHEET_TRANSITION_MS) +} + +watch(() => props.shown, (shown) => { + void (shown ? enterSurface() : leaveSurface()) +}, { + immediate: true, +}) + +onBeforeUnmount(() => { + clearTransitionTimer() +}) + diff --git a/m3-vue/src/components/surface/M3Surface.vue b/m3-vue/src/components/surface/M3Surface.vue new file mode 100644 index 0000000..4b3c76b --- /dev/null +++ b/m3-vue/src/components/surface/M3Surface.vue @@ -0,0 +1,202 @@ + + + diff --git a/m3-vue/src/components/surface/M3SurfacePanel.vue b/m3-vue/src/components/surface/M3SurfacePanel.vue new file mode 100644 index 0000000..05036e7 --- /dev/null +++ b/m3-vue/src/components/surface/M3SurfacePanel.vue @@ -0,0 +1,52 @@ + + + diff --git a/m3-vue/src/components/surface/index.ts b/m3-vue/src/components/surface/index.ts new file mode 100644 index 0000000..5db01f7 --- /dev/null +++ b/m3-vue/src/components/surface/index.ts @@ -0,0 +1,2 @@ +export { default as M3SurfacePanel } from './M3SurfacePanel.vue' +export { default as M3Surface } from './M3Surface.vue' diff --git a/m3-vue/src/components/surface/orchestration/useSurfaceCardPageMorph.ts b/m3-vue/src/components/surface/orchestration/useSurfaceCardPageMorph.ts new file mode 100644 index 0000000..13a850b --- /dev/null +++ b/m3-vue/src/components/surface/orchestration/useSurfaceCardPageMorph.ts @@ -0,0 +1,201 @@ +/* eslint-disable max-lines-per-function */ +import { + computed, + onBeforeUnmount, + nextTick, + onMounted, + ref, +} from 'vue' + +import { + measureContainerRect, + measureRelativeRect, + raf, + toMotionStyle, + type SurfaceMotionRect, + wait, +} from '@modulify/m3-foundation/lib/surface/orchestration' + +export function useSurfaceCardPageMorph(transitionMs: number) { + const expanded = ref(false) + const busy = ref(false) + const backgroundCollapsed = ref(false) + const originHeight = ref(220) + const canvas = ref(null) + const originSlot = ref(null) + let observer: ResizeObserver | null = null + const syncFrame = ref(null) + const motion = ref({ + top: 16, + left: 16, + width: 320, + height: 220, + }) + + const overlayStyle = computed(() => toMotionStyle(motion.value)) + + function measureOrigin() { + return measureRelativeRect(canvas.value, originSlot.value) + } + + function measureExpanded() { + return measureContainerRect(canvas.value) + } + + function syncMotionToLayout() { + const origin = measureOrigin() + + if (origin) { + originHeight.value = origin.height + } + + if (expanded.value) { + const full = measureExpanded() + + if (full) { + motion.value = full + } + + return + } + + if (!origin || backgroundCollapsed.value) { + return + } + + motion.value = origin + } + + function scheduleSyncMotion() { + if (syncFrame.value !== null) { + cancelAnimationFrame(syncFrame.value) + } + + syncFrame.value = requestAnimationFrame(() => { + syncFrame.value = null + syncMotionToLayout() + }) + } + + async function initMotion() { + await nextTick() + await raf() + + const origin = measureOrigin() + if (!origin) { + return + } + + motion.value = origin + originHeight.value = origin.height + } + + async function expandCard() { + backgroundCollapsed.value = false + + const origin = measureOrigin() + if (origin) { + motion.value = origin + originHeight.value = origin.height + } + + expanded.value = true + await nextTick() + await raf() + + const full = measureExpanded() + if (!full) { + return + } + + motion.value = full + await wait(transitionMs) + backgroundCollapsed.value = true + } + + async function collapseCard() { + if (backgroundCollapsed.value) { + backgroundCollapsed.value = false + await nextTick() + await raf() + } + + const full = measureExpanded() + if (full) { + motion.value = full + } + + const origin = measureOrigin() + if (origin) { + originHeight.value = origin.height + } + + expanded.value = false + await nextTick() + await raf() + + if (!origin) { + return + } + + motion.value = origin + await wait(transitionMs) + } + + async function toggleCardMode() { + if (busy.value) { + return + } + + busy.value = true + + if (expanded.value) { + await collapseCard() + busy.value = false + return + } + + await expandCard() + busy.value = false + } + + onMounted(() => { + initMotion() + + if (typeof ResizeObserver === 'undefined') { + return + } + + observer = new ResizeObserver(() => { + scheduleSyncMotion() + }) + + if (canvas.value && observer) { + observer.observe(canvas.value) + } + + if (originSlot.value && observer) { + observer.observe(originSlot.value) + } + }) + + onBeforeUnmount(() => { + observer?.disconnect() + + if (syncFrame.value !== null) { + cancelAnimationFrame(syncFrame.value) + syncFrame.value = null + } + }) + + return { + expanded, + busy, + backgroundCollapsed, + originHeight, + overlayStyle, + canvas, + originSlot, + toggleCardMode, + } +} diff --git a/m3-vue/src/components/surface/orchestration/useSurfaceSideSheetMorph.ts b/m3-vue/src/components/surface/orchestration/useSurfaceSideSheetMorph.ts new file mode 100644 index 0000000..e4f72cb --- /dev/null +++ b/m3-vue/src/components/surface/orchestration/useSurfaceSideSheetMorph.ts @@ -0,0 +1,266 @@ +/* eslint-disable max-lines-per-function */ +import { + computed, + nextTick, + onBeforeUnmount, + onMounted, + ref, +} from 'vue' + +import { getSurfaceStateDescriptor } from '@modulify/m3-foundation/lib/surface/descriptor' +import { m3MotionDurations } from '@modulify/m3-foundation/lib/motion' + +import { + raf, + wait, +} from '@modulify/m3-foundation/lib/surface/orchestration' + +const SIDE_SHEET_WIDTH_MIN = 280 +const SIDE_SHEET_WIDTH_MAX = 360 +const SIDE_SHEET_WIDTH_RATIO = 32 + +const MODAL_INSET_TOP = 0 +const MODAL_INSET_BOTTOM = 0 +const MODAL_INSET_END = 0 +const PANEL_TRANSITION_MS = m3MotionDurations.medium2 +const DOCKED_SIDE_SHEET_DESCRIPTOR = getSurfaceStateDescriptor('docked_side_sheet') +const MODAL_SIDE_SHEET_DESCRIPTOR = getSurfaceStateDescriptor('modal_side_sheet') +const DOCKED_HOST_WIDTH = `clamp(${SIDE_SHEET_WIDTH_MIN}px, ${SIDE_SHEET_WIDTH_RATIO}%, ${SIDE_SHEET_WIDTH_MAX}px)` + +export function useSurfaceSideSheetMorph() { + type SurfaceGeometry = { + width: number; + insetTop: number; + insetRight: number; + insetBottom: number; + } + + const sideSheetModal = ref(false) + const sideSheetWidth = ref(SIDE_SHEET_WIDTH_MIN) + const dockedHostExpanded = ref(true) + const dockedPanelShown = ref(true) + const dockedPanelHidden = ref(false) + const modalShown = ref(false) + const modalScrimShown = ref(false) + const modalWidth = ref(sideSheetWidth.value) + const modalInsetTop = ref(MODAL_INSET_TOP) + const modalInsetRight = ref(MODAL_INSET_END) + const modalInsetBottom = ref(MODAL_INSET_BOTTOM) + const modalRadiusLeft = ref(DOCKED_SIDE_SHEET_DESCRIPTOR.rounding.topLeft) + const modalElevation = ref(DOCKED_SIDE_SHEET_DESCRIPTOR.elevation) + const modalRole = ref<'surface-container-low' | 'surface-container-high'>(DOCKED_SIDE_SHEET_DESCRIPTOR.variant) + const modalTransitionMs = ref(PANEL_TRANSITION_MS) + const transitioning = ref(false) + const lastDockedGeometry = ref(null) + const dockedHost = ref(null) + const layoutRoot = ref(null) + + function measureDockedGeometry() { + const host = dockedHost.value + if (!host) { + return null + } + + const rect = host.getBoundingClientRect() + const width = Math.round(rect.width) + + if (width > 0) { + sideSheetWidth.value = width + } + + return { + width: width > 0 ? width : sideSheetWidth.value, + insetTop: Math.round(rect.top), + insetRight: Math.round(window.innerWidth - rect.right), + insetBottom: Math.round(window.innerHeight - rect.bottom), + } + } + + function measureDockedPanelGeometry(): SurfaceGeometry | null { + const panel = dockedHost.value?.querySelector('[data-panel-mode="docked"]') as HTMLElement | null + if (!panel) { + return null + } + + const rect = panel.getBoundingClientRect() + const geometry = { + width: Math.round(rect.width), + insetTop: Math.round(rect.top), + insetRight: Math.round(window.innerWidth - rect.right), + insetBottom: Math.round(window.innerHeight - rect.bottom), + } + + sideSheetWidth.value = geometry.width + lastDockedGeometry.value = geometry + + return geometry + } + + async function syncDockedGeometry() { + await nextTick() + await raf() + + const docked = measureDockedPanelGeometry() ?? measureDockedGeometry() + if (!docked || sideSheetModal.value) { + return + } + + modalWidth.value = docked.width + modalInsetTop.value = docked.insetTop + modalInsetRight.value = docked.insetRight + modalInsetBottom.value = docked.insetBottom + } + + async function switchDockedToModal() { + const docked = measureDockedPanelGeometry() ?? measureDockedGeometry() + if (!docked) { + return + } + + modalTransitionMs.value = PANEL_TRANSITION_MS + modalWidth.value = docked.width + modalInsetTop.value = docked.insetTop + modalInsetRight.value = docked.insetRight + modalInsetBottom.value = docked.insetBottom + modalRadiusLeft.value = DOCKED_SIDE_SHEET_DESCRIPTOR.rounding.topLeft + modalElevation.value = DOCKED_SIDE_SHEET_DESCRIPTOR.elevation + modalRole.value = DOCKED_SIDE_SHEET_DESCRIPTOR.variant + dockedPanelShown.value = true + dockedPanelHidden.value = true + modalShown.value = true + + await nextTick() + await raf() + + modalScrimShown.value = MODAL_SIDE_SHEET_DESCRIPTOR.scrim + dockedHostExpanded.value = false + sideSheetModal.value = true + modalWidth.value = docked.width + modalInsetTop.value = MODAL_INSET_TOP + modalInsetRight.value = MODAL_INSET_END + modalInsetBottom.value = MODAL_INSET_BOTTOM + modalRadiusLeft.value = MODAL_SIDE_SHEET_DESCRIPTOR.rounding.topLeft + modalElevation.value = MODAL_SIDE_SHEET_DESCRIPTOR.elevation + modalRole.value = MODAL_SIDE_SHEET_DESCRIPTOR.variant + + await wait(PANEL_TRANSITION_MS) + + dockedPanelShown.value = false + } + + async function switchModalToDocked() { + dockedHostExpanded.value = true + dockedPanelShown.value = true + dockedPanelHidden.value = true + + await nextTick() + await raf() + + const dockedTarget = lastDockedGeometry.value ?? measureDockedPanelGeometry() ?? measureDockedGeometry() + if (!dockedTarget) { + return + } + + modalTransitionMs.value = PANEL_TRANSITION_MS + modalScrimShown.value = false + modalElevation.value = DOCKED_SIDE_SHEET_DESCRIPTOR.elevation + modalRole.value = DOCKED_SIDE_SHEET_DESCRIPTOR.variant + modalRadiusLeft.value = DOCKED_SIDE_SHEET_DESCRIPTOR.rounding.topLeft + modalWidth.value = dockedTarget.width + modalInsetTop.value = dockedTarget.insetTop + modalInsetRight.value = dockedTarget.insetRight + modalInsetBottom.value = dockedTarget.insetBottom + + await wait(PANEL_TRANSITION_MS) + + sideSheetModal.value = false + modalShown.value = false + dockedPanelHidden.value = false + } + + async function toggleSideSheetMode() { + if (transitioning.value) { + return + } + + transitioning.value = true + + if (!sideSheetModal.value) { + await switchDockedToModal() + transitioning.value = false + return + } + + await switchModalToDocked() + transitioning.value = false + } + + async function closeModalFromPanel() { + if (!sideSheetModal.value || transitioning.value) { + return + } + + transitioning.value = true + await switchModalToDocked() + transitioning.value = false + } + + async function onResize() { + if (dockedHostExpanded.value) { + await syncDockedGeometry() + } + } + + onMounted(async () => { + await syncDockedGeometry() + window.addEventListener('resize', onResize, { passive: true }) + }) + + onBeforeUnmount(() => { + window.removeEventListener('resize', onResize) + }) + + const dockedHostStyle = computed(() => ({ + width: dockedHostExpanded.value ? DOCKED_HOST_WIDTH : '0px', + })) + + const dockedPanelStyle = computed(() => ({ + visibility: dockedPanelHidden.value ? 'hidden' : 'visible', + })) + + const modalPanelProps = computed(() => ({ + shown: true as const, + scrimShown: modalScrimShown.value, + anchor: MODAL_SIDE_SHEET_DESCRIPTOR.anchor, + fillWidth: MODAL_SIDE_SHEET_DESCRIPTOR.fillWidth, + fillHeight: MODAL_SIDE_SHEET_DESCRIPTOR.fillHeight, + width: modalWidth.value, + insetTop: modalInsetTop.value, + insetRight: modalInsetRight.value, + insetBottom: modalInsetBottom.value, + roundingTopLeft: modalRadiusLeft.value, + roundingBottomLeft: modalRadiusLeft.value, + roundingTopRight: MODAL_SIDE_SHEET_DESCRIPTOR.rounding.topRight, + roundingBottomRight: MODAL_SIDE_SHEET_DESCRIPTOR.rounding.bottomRight, + transitionMs: modalTransitionMs.value, + zIndex: 520, + variant: modalRole.value, + elevation: modalElevation.value, + overflow: MODAL_SIDE_SHEET_DESCRIPTOR.overflow, + })) + + return { + sideSheetModal, + sideSheetWidth, + transitioning, + modalShown, + dockedPanelShown, + dockedHost, + layoutRoot, + dockedHostStyle, + dockedPanelStyle, + modalPanelProps, + toggleSideSheetMode, + closeModalFromPanel, + } +} diff --git a/m3-vue/src/components/surface/shared.ts b/m3-vue/src/components/surface/shared.ts new file mode 100644 index 0000000..60919fd --- /dev/null +++ b/m3-vue/src/components/surface/shared.ts @@ -0,0 +1,163 @@ +import type { + CSSProperties, + PropType, +} from 'vue' +import type { + Length as SurfaceLength, + Variant as SurfaceVariant, +} from '@modulify/m3-foundation/types/components/surface' + +import { + DEFAULT_SURFACE_TRANSITION_TIMING, + getSurfacePanelClassRecord, + getSurfacePanelStyle as getFoundationSurfacePanelStyle, +} from '@modulify/m3-foundation/lib/surface/style' + +export { + getModalAnchorStyle, + getResolvedVariant, + getSurfaceTransition, + getVariantClassName, + isDefined, + toLength, +} from '@modulify/m3-foundation/lib/surface/style' +import { + isId, + isUndefined, + Or, +} from '@modulify/m3-foundation/lib/predicates' + +export const surfacePanelProps = { + id: { + type: null as unknown as PropType, + validator: Or(isId, isUndefined), + default: undefined, + }, + + tag: { + type: String, + default: 'section', + }, + + elevation: { + type: Number, + default: 0, + validator: (value: number) => Number.isInteger(value) && value >= 0 && value <= 5, + }, + + variant: { + type: String as PropType, + default: 'auto', + }, + + fillWidth: { + type: Boolean, + default: true, + }, + + fillHeight: { + type: Boolean, + default: true, + }, + + width: { + type: null as unknown as PropType, + default: null, + }, + + height: { + type: null as unknown as PropType, + default: null, + }, + + minWidth: { + type: null as unknown as PropType, + default: null, + }, + + maxWidth: { + type: null as unknown as PropType, + default: null, + }, + + minHeight: { + type: null as unknown as PropType, + default: null, + }, + + maxHeight: { + type: null as unknown as PropType, + default: null, + }, + + rounding: { + type: null as unknown as PropType, + default: 0, + }, + + roundingTopLeft: { + type: null as unknown as PropType, + default: null, + }, + + roundingTopRight: { + type: null as unknown as PropType, + default: null, + }, + + roundingBottomRight: { + type: null as unknown as PropType, + default: null, + }, + + roundingBottomLeft: { + type: null as unknown as PropType, + default: null, + }, + + transitionMs: { + type: Number, + default: 220, + }, + + transitionTiming: { + type: String, + default: DEFAULT_SURFACE_TRANSITION_TIMING, + }, + + overflow: { + type: String, + default: 'visible', + }, +} as const + +export function getSurfacePanelClass(elevation: number, variant: SurfaceVariant) { + return getSurfacePanelClassRecord(elevation, variant) +} + +type SurfacePanelStyleInput = { + fillWidth: boolean, + fillHeight: boolean, + width: SurfaceLength | null, + height: SurfaceLength | null, + minWidth: SurfaceLength | null, + maxWidth: SurfaceLength | null, + minHeight: SurfaceLength | null, + maxHeight: SurfaceLength | null, + rounding: SurfaceLength, + roundingTopLeft: SurfaceLength | null, + roundingTopRight: SurfaceLength | null, + roundingBottomRight: SurfaceLength | null, + roundingBottomLeft: SurfaceLength | null, + transitionMs: number, + transitionTiming: string, + overflow: CSSProperties['overflow'], + style?: CSSProperties, +} + +export const getSurfacePanelStyle = ( + options: SurfacePanelStyleInput +): CSSProperties => getFoundationSurfacePanelStyle({ + ...options, + style: options.style as object | undefined, +}) as CSSProperties diff --git a/m3-vue/src/index.ts b/m3-vue/src/index.ts index 43ff0f8..b8ad7e4 100644 --- a/m3-vue/src/index.ts +++ b/m3-vue/src/index.ts @@ -78,6 +78,11 @@ export { M3SideSheet, } from '@/components/side-sheet' +export { + M3Surface, + M3SurfacePanel, +} from '@/components/surface' + export { M3Slider, } from '@/components/slider' @@ -89,4 +94,4 @@ export { export { M3TextField, M3TextFieldSupportText, -} from '@/components/text-field' \ No newline at end of file +} from '@/components/text-field' diff --git a/m3-vue/storybook/components/Inline.ts b/m3-vue/storybook/components/Inline.ts index 3593786..734fc4c 100644 --- a/m3-vue/storybook/components/Inline.ts +++ b/m3-vue/storybook/components/Inline.ts @@ -5,28 +5,53 @@ import { v4 } from 'uuid' import { createApp, h } from 'vue' +const normalizeIdSegment = (value: string): string => { + return value.replace(/[^a-zA-Z0-9_-]/g, '') +} + +const buildInlineIdPrefix = (reactId: string, uuid: string): string => { + return `m3-inline-${normalizeIdSegment(reactId)}-${normalizeIdSegment(uuid)}-` +} + +const mountInlineApp = ({ appIdPrefix, children, is, props, root }) => { + const id = v4() + + const app = createApp({ + mounted () { + if (children) { + ReactDOM.render( + React.createElement(React.Fragment, {}, children), + document.getElementById(id) + ) + } + }, + + render: () => h(is, props), + }) + + app.config.idPrefix = appIdPrefix + app.mount(root) + + return () => app.unmount() +} + export default ({ is, children, tag, ...props }) => { const ref = React.useRef(null) + const reactId = React.useId() + const uuidRef = React.useRef(v4()) + const appIdPrefix = React.useMemo( + () => buildInlineIdPrefix(reactId, uuidRef.current), + [reactId] + ) React.useEffect(() => { - const id = v4() - - const app = createApp({ - mounted () { - if (children) { - ReactDOM.render( - React.createElement(React.Fragment, {}, children), - document.getElementById(id) - ) - } - }, - - render: () => h(is, props), + return mountInlineApp({ + appIdPrefix, + children, + is, + props, + root: ref.current, }) - - app.mount(ref.current) - - return () => app.unmount() }) return React.createElement(tag ?? 'div', { diff --git a/m3-vue/storybook/components/M3Button.mdx b/m3-vue/storybook/components/M3Button.mdx index 13c6818..c3b4276 100644 --- a/m3-vue/storybook/components/M3Button.mdx +++ b/m3-vue/storybook/components/M3Button.mdx @@ -7,7 +7,31 @@ import * as M3ButtonStories from './M3Button.stories' # Buttons -Common buttons prompt most actions in a UI +Buttons communicate available actions and establish hierarchy on a surface. Material 3 distinguishes buttons primarily by emphasis, not by feature set. + +## API + +### When to use + +Use `M3Button` for explicit actions such as save, submit, share, or continue. Prefer buttons over links when the result changes UI state or triggers an in-app operation. + +### Appearances + +- `filled` for the primary action in a region +- `elevated` when the action should stand out on low-emphasis surfaces +- `tonal` for prominent but secondary emphasis +- `outlined` for lower-emphasis alternatives +- `text` for lightweight supporting actions + +### Icons and labels + +Leading icons help scanning when the metaphor is widely understood. Keep the text label explicit even when an icon is present, so the action remains readable without relying on symbol recognition alone. + +## Accessibility semantics + +- Use a clear text label for action buttons. +- For icon-only actions, provide `aria-label`. +- Keep disabled actions unavailable through the `disabled` state.

@@ -24,3 +48,34 @@ Common buttons prompt most actions in a UI

+ +## Story guide + +- [With Text Only](?path=/story/components-m3button--with-text-only) +- [With Leading Icon](?path=/story/components-m3button--with-leading-icon) +- [Appearance Matrix](?path=/story/components-m3button--appearance-matrix) +- [Disabled States](?path=/story/components-m3button--disabled-states) + +## Usage guidance + +### Keep hierarchy local + +Each region should usually have one highest-emphasis action. If everything is filled, users lose the sense of priority that Material 3 buttons are designed to communicate. + +### Disable carefully + +Use `disabled` only when the action is genuinely unavailable. If the action is available but risky, keep it enabled and explain the consequence instead of hiding the path forward. + +## Code example + +```html + + Edit + +``` + +## Resources + +- [M3 Buttons overview](https://m3.material.io/components/buttons/overview) +- [M3 Buttons guidelines](https://m3.material.io/components/buttons/guidelines) +- [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) diff --git a/m3-vue/storybook/components/M3Button.stories.ts b/m3-vue/storybook/components/M3Button.stories.ts index cc290fc..369f569 100644 --- a/m3-vue/storybook/components/M3Button.stories.ts +++ b/m3-vue/storybook/components/M3Button.stories.ts @@ -68,3 +68,71 @@ export const WithLeadingIcon: Story = { `, }), } + +export const AppearanceMatrix: Story = { + // eslint-disable-next-line max-lines-per-function + render: () => ({ + components: { + M3Button, + M3Icon, + }, + + setup () { + return { + appearances: values.appearances, + } + }, + + template: ` +
+
+ + Share + +
+ +
+ + + Share + +
+
+ `, + }), +} + +export const DisabledStates: Story = { + render: () => ({ + components: { + M3Button, + }, + + setup () { + return { + appearances: values.appearances, + } + }, + + template: ` +
+ + Share + +
+ `, + }), +} diff --git a/m3-vue/storybook/components/M3Card.mdx b/m3-vue/storybook/components/M3Card.mdx index a458dad..9cc6882 100644 --- a/m3-vue/storybook/components/M3Card.mdx +++ b/m3-vue/storybook/components/M3Card.mdx @@ -10,9 +10,32 @@ import { defineComponent, h } from 'vue' # Cards -Cards display content and actions about a single subject +Cards group related content and actions into a single container. In Material 3 they work best when they stay focused on one subject, one decision, or one compact summary. -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## API + +### When to use + +Use `M3Card` when content should feel grouped but still remain embedded in the page flow. If the interaction becomes blocking or needs a scrim, move to `M3Dialog` or `M3Surface`. + +### Anatomy + +Typical card structure in this workspace includes: + +- media or leading visual +- heading and optional subheading +- supporting content +- optional trailing or footer actions + +### Appearances + +Material 3 cards rely on tone and outline treatment to signal emphasis. This implementation exposes `filled`, `elevated`, and `outlined`, which cover the main hierarchy levels from the official card guidance. + +## Accessibility semantics + +- Use non-interactive cards as plain content containers. +- For interactive cards, expose actions with semantic controls (`button` / `link`). +- Keep a clear heading hierarchy inside cards for screen reader navigation. h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '16px' } }, [ @@ -20,3 +43,46 @@ Cards display content and actions about a single subject h(LiveMusic2), ]), })} /> + +## Story guide + +- [Landscape](?path=/story/components-m3card--landscape) +- [Landscape Without Media](?path=/story/components-m3card--landscape-without-media) +- [Portrait](?path=/story/components-m3card--portrait) +- [Appearance Matrix](?path=/story/components-m3card--appearance-matrix) + +## Usage guidance + +### Keep cards focused + +Avoid turning a card into a mini page. Once it needs long forms, deep navigation, or persistent editing controls, a larger surface usually communicates the task more clearly. + +### Separate container click from inner actions + +If the whole card is interactive, nested actions should remain semantically distinct and visually predictable. Competing click targets inside one card quickly become ambiguous. + +## Code example + +```html + + + + + + + + Supporting text + +``` + +## Resources + +- [M3 Cards overview](https://m3.material.io/components/cards/overview) +- [M3 Cards guidelines](https://m3.material.io/components/cards/guidelines) +- [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) diff --git a/m3-vue/storybook/components/M3Card.stories.ts b/m3-vue/storybook/components/M3Card.stories.ts index f6c009b..7084269 100644 --- a/m3-vue/storybook/components/M3Card.stories.ts +++ b/m3-vue/storybook/components/M3Card.stories.ts @@ -132,3 +132,39 @@ export const Portrait: Story = { `, }), } + +export const AppearanceMatrix: Story = { + // eslint-disable-next-line max-lines-per-function + render: () => ({ + components: { + M3Card, + }, + + setup () { + return { + appearances: ['filled', 'elevated', 'outlined'], + } + }, + + template: ` +
+ + + + + + Supporting text for the current card style. + +
+ `, + }), +} diff --git a/m3-vue/storybook/components/M3Checkbox.mdx b/m3-vue/storybook/components/M3Checkbox.mdx index 459511e..5199ab7 100644 --- a/m3-vue/storybook/components/M3Checkbox.mdx +++ b/m3-vue/storybook/components/M3Checkbox.mdx @@ -7,11 +7,31 @@ import * as M3CheckboxStories from './M3Checkbox.stories' # Checkboxes -Checkboxes let users select one or more items from a list, or turn an item on or off +Checkboxes let users select one or more options independently. They also support parent-child selection patterns where a parent reflects the aggregate state of subordinate items. -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## API -[Guidelines](https://m3.material.io/components/checkbox/guidelines) +### When to use + +Use `M3Checkbox` when multiple items can be selected at the same time, or when a single option behaves like a boolean choice inside a form. If the user must pick exactly one option, prefer radios instead. + +### Selection model + +This implementation supports both simple boolean selection and array-backed multi-select models. It also supports `indeterminate` for aggregate parent rows, which matches the Material 3 guidance for nested selection patterns. + +### Nested selection + +In nested lists, the parent checkbox should summarize descendant state: + +- checked when all descendants are selected +- indeterminate when some descendants are selected +- unchecked when none are selected + +## Accessibility semantics + +- Every checkbox needs a visible text label. +- Group related checkboxes under a shared group label (`fieldset/legend` or `aria-labelledby`). +- Use indeterminate state only for partial parent-selection states. ### Regular list @@ -42,3 +62,24 @@ Checkboxes let users select one or more items from a list, or turn an item on or value: 'monthly', }], }]} /> + +## Story guide + +- [Standard](?path=/story/components-m3checkbox--standard) +- [Nested Selection](?path=/story/components-m3checkbox--nested-selection) + +## Usage guidance + +### Keep labels explicit + +Checkbox labels should describe the resulting state, not the control itself. "Email receipts" is clearer than "Enable." + +### Use indeterminate only as summary state + +Do not present `indeterminate` as a user-selectable third state. It should only communicate partial selection across descendants. + +## Resources + +- [M3 Checkbox overview](https://m3.material.io/components/checkbox/overview) +- [M3 Checkbox guidelines](https://m3.material.io/components/checkbox/guidelines) +- [WAI-ARIA APG: Checkbox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/) diff --git a/m3-vue/storybook/components/M3Checkbox.stories.ts b/m3-vue/storybook/components/M3Checkbox.stories.ts index 30febee..d30f3b7 100644 --- a/m3-vue/storybook/components/M3Checkbox.stories.ts +++ b/m3-vue/storybook/components/M3Checkbox.stories.ts @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/vue3' import { M3Checkbox } from '@/components/checkbox' +import CheckboxList from '../examples/checkbox/CheckboxList.vue' import { ref } from 'vue' import useId from '@/composables/id' @@ -48,3 +49,30 @@ export default meta type Story = StoryObj export const Standard: Story = {} + +export const NestedSelection: Story = { + render: () => ({ + components: { + CheckboxList, + }, + + template: ` + + `, + }), +} diff --git a/m3-vue/storybook/components/M3Dialog.mdx b/m3-vue/storybook/components/M3Dialog.mdx index c7b63f8..8601237 100644 --- a/m3-vue/storybook/components/M3Dialog.mdx +++ b/m3-vue/storybook/components/M3Dialog.mdx @@ -5,8 +5,100 @@ import DialogConfirmation from '../examples/dialog/DialogConfirmation.vue' # Dialogs +Dialogs communicate important information and block the underlying interface until the user responds. + +## API + +### When to use + +Use `M3Dialog` for short, interruptive decisions that need an explicit user choice before the page can continue. In Material 3 terms this maps best to confirmation, acknowledgement, and blocking task flows rather than to long-form editing or navigation. + +### Structure + +`M3Dialog` keeps the same content anatomy across React and Vue: + +- `icon` slot for a leading visual cue +- `header` slot for the title area +- default slot for supporting text or compact form controls +- `footer` slot for high-signal actions + +The component now renders on top of `M3Surface`, so dialog presentation and motion are aligned with the same modal surface vocabulary used by side sheets and orchestration stories. + +### Behavior in this implementation + +- Non-fullscreen dialogs open as centered modal surfaces with a scrim. +- Fullscreen dialogs remove the scrim and expand to the viewport. +- The surface enters with a short fade plus a slight upward settle, matching the modal choreography used in the nested-surface stories. +- Dialog content stays mounted briefly during exit so the leave animation can finish before unmount. + +## Accessibility semantics + +- Set `role="dialog"` (or `alertdialog` for urgent confirmations). +- Provide `aria-modal="true"` for modal flows. +- Connect title and description with `aria-labelledby` and `aria-describedby`. +- Keep focus inside the dialog while it is opened. +- Keep action labels explicit and outcome-oriented. +
-
\ No newline at end of file + + +## Usage guidance + +### Content density + +Prefer one short title, one supporting message, and one clear primary action. If the flow starts needing dense forms, multiple scrolling regions, or navigation-like exploration, a side sheet or dedicated page usually communicates the state change more clearly. + +### Actions + +- Use the footer for the final decision point. +- Keep destructive actions explicit in both label and placement. +- Prefer a dismissive secondary action such as `Cancel` over relying on scrim click alone. + +### Fullscreen mode + +Use `fullscreen` only when the task needs the full viewport or when compact dialog proportions would constrain readability. This mode still keeps dialog semantics, but visually behaves closer to a temporary task surface than to a small confirmation window. + +## Code example + +```html + + + + + +

+ Deleting the selected messages will also remove them from all synced devices. +

+ + +
+``` + +## Resources + +- [M3 Dialogs overview](https://m3.material.io/components/dialogs/overview) +- [M3 Dialogs guidelines](https://m3.material.io/components/dialogs/guidelines) +- [WAI-ARIA APG: Modal Dialog Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) diff --git a/m3-vue/storybook/components/M3FabButton.mdx b/m3-vue/storybook/components/M3FabButton.mdx index 02b7e0f..4a04a5a 100644 --- a/m3-vue/storybook/components/M3FabButton.mdx +++ b/m3-vue/storybook/components/M3FabButton.mdx @@ -7,13 +7,32 @@ import * as M3FabButtonStories from './M3FabButton.stories' # Floating action buttons -Floating action buttons (FABs) help people take primary actions +Floating action buttons emphasize the highest-priority action on a surface. Material 3 treats FABs as strong directional cues, so they work best when the page has one clear "next thing to do." -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## API -### Standard FABs +### When to use + +Use `M3FabButton` for the primary action of a screen or large section, especially when that action should remain easy to reach while content scrolls. + +### Standard vs extended + +- standard FAB for compact icon-first actions +- extended FAB when the action benefits from a visible label + +The extended form is usually easier to scan when the action is not universally obvious from the icon alone. + +### Variants -[Guidelines](https://m3.material.io/components/floating-action-button/guidelines) +This implementation exposes Material 3 tonal variants such as `surface`, `primary`, `secondary`, and `tertiary`, which let the FAB stay prominent without always defaulting to the same tone. + +## Accessibility semantics + +- Icon-only FABs require an accessible name via `aria-label`. +- Keep FAB usage focused on the primary action on a given surface. +- Extended FABs should keep a short visible label. + +### Standard FABs

@@ -24,11 +43,42 @@ Floating action buttons (FABs) help people take primary actions ### Extended FABs -[Guidelines](https://m3.material.io/components/extended-fab/guidelines) -

+ +## Story guide + +- [Standard](?path=/story/components-m3fabbutton--standard) +- [Extended](?path=/story/components-m3fabbutton--extended) +- [Variant Matrix](?path=/story/components-m3fabbutton--variant-matrix) +- [Size Matrix](?path=/story/components-m3fabbutton--size-matrix) + +## Usage guidance + +### One FAB per context + +Most surfaces should have at most one FAB. Multiple competing FABs dilute the main-action pattern that Material 3 is trying to reinforce. + +### Prefer extended when the icon is ambiguous + +If users need to stop and decode the icon, show the label. The slight increase in width is usually worth the clarity. + +## Code example + +```html + + New task + +``` + +## Resources + +- [M3 FAB overview](https://m3.material.io/components/floating-action-button/overview) +- [M3 FAB guidelines](https://m3.material.io/components/floating-action-button/guidelines) +- [M3 Extended FAB overview](https://m3.material.io/components/extended-fab/overview) +- [M3 Extended FAB guidelines](https://m3.material.io/components/extended-fab/guidelines) +- [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) diff --git a/m3-vue/storybook/components/M3FabButton.stories.ts b/m3-vue/storybook/components/M3FabButton.stories.ts index 4e89fc7..6e22250 100644 --- a/m3-vue/storybook/components/M3FabButton.stories.ts +++ b/m3-vue/storybook/components/M3FabButton.stories.ts @@ -81,3 +81,72 @@ export const Extended: Story = { `, }), } + +export const VariantMatrix: Story = { + // eslint-disable-next-line max-lines-per-function + render: () => ({ + components: { + M3FabButton, + M3Icon, + }, + + setup () { + return { + variants, + } + }, + + template: ` +
+
+ + + +
+ +
+ + New task + +
+
+ `, + }), +} + +export const SizeMatrix: Story = { + render: () => ({ + components: { + M3FabButton, + M3Icon, + }, + + setup () { + return { + sizes, + } + }, + + template: ` +
+ + + +
+ `, + }), +} diff --git a/m3-vue/storybook/components/M3IconButton.mdx b/m3-vue/storybook/components/M3IconButton.mdx index 8bbafb0..8d66c8d 100644 --- a/m3-vue/storybook/components/M3IconButton.mdx +++ b/m3-vue/storybook/components/M3IconButton.mdx @@ -8,11 +8,29 @@ import { M3IconButton } from '@/components/icon-button' -# M3IconButton +# Icon buttons -Icon buttons help people take minor actions with one tap +Icon buttons surface compact, frequent actions without introducing the visual weight of a full text button. They work best for toolbar actions, trailing row actions, and repeated local controls. -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## API + +### When to use + +Use `M3IconButton` for secondary or utility actions where the icon metaphor is well established. If the action is high-stakes or not obvious from the icon alone, a text button is usually clearer. + +### Appearances + +Material 3 distinguishes icon buttons by fill and emphasis. This implementation covers `standard`, `filled`, `tonal`, and `outlined`, which map well to common local-action hierarchies. + +### Toggleable state + +When `toggleable` is enabled, the button should represent an on/off state such as favorite, pin, mute, or bookmark. In that mode, use selection styling together with `aria-pressed`. + +## Accessibility semantics + +- Icon-only controls must include an accessible name (`aria-label`). +- Toggleable icon buttons should expose state with `aria-pressed`. +- Use icon buttons for minor actions, not for primary destructive actions. [ - h(M3IconButton, { appearance: 'filled' }, icon), + h(M3IconButton, { + appearance: 'filled', + 'aria-label': 'Mark as favorite', + }, icon), h(M3IconButton, { appearance: 'filled', selected: selected.value, toggleable: true, + 'aria-label': 'Toggle favorite', + 'aria-pressed': selected.value ? 'true' : 'false', onClick: () => selected.value = !selected.value, - }, icon) + }, icon), ] }, })} /> + +## Story guide + +- [Standard](?path=/story/components-m3iconbutton--standard) +- [Toggleable](?path=/story/components-m3iconbutton--toggleable) +- [Appearance Matrix](?path=/story/components-m3iconbutton--appearance-matrix) + +## Usage guidance + +### Keep hit targets generous + +Even when the visual icon is small, the interactive target should remain comfortable for pointer and touch use. Material 3 icon buttons depend on this spacing to feel intentional rather than fiddly. + +### Use toggles only for persistent state + +If the action simply opens a menu or triggers a one-off command, do not model it as selected. Reserve toggle state for something the user can inspect later. + +## Code example + +```html + + + +``` + +## Resources + +- [M3 Icon Buttons overview](https://m3.material.io/components/icon-buttons/overview) +- [M3 Icon Buttons guidelines](https://m3.material.io/components/icon-buttons/guidelines) +- [WAI-ARIA APG: Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) diff --git a/m3-vue/storybook/components/M3IconButton.stories.ts b/m3-vue/storybook/components/M3IconButton.stories.ts index 430a0a1..330e77d 100644 --- a/m3-vue/storybook/components/M3IconButton.stories.ts +++ b/m3-vue/storybook/components/M3IconButton.stories.ts @@ -90,3 +90,46 @@ export const Toggleable: Story = { `, }), } + +export const AppearanceMatrix: Story = { + // eslint-disable-next-line max-lines-per-function + render: () => ({ + components: { + M3Icon, + M3IconButton, + }, + + setup () { + return { + appearances: ['standard', 'filled', 'tonal', 'outlined'], + } + }, + + template: ` +
+
+ + + +
+ +
+ + + +
+
+ `, + }), +} diff --git a/m3-vue/storybook/components/M3Navigation.mdx b/m3-vue/storybook/components/M3Navigation.mdx index 856bd5a..8fcf4fb 100644 --- a/m3-vue/storybook/components/M3Navigation.mdx +++ b/m3-vue/storybook/components/M3Navigation.mdx @@ -4,3 +4,48 @@ import * as M3NavigationStories from './M3Navigation.stories' # Navigation + +Navigation components help users move between top-level destinations. Material 3 splits navigation patterns by available space and permanence, not only by visual style. + +## API + +### When to use + +Use `M3Navigation` for app-level destinations that users may revisit repeatedly during the session. Keep these destinations stable and semantically distinct from local page tabs or in-content filters. + +### Navigation forms + +- navigation drawer for broad destination sets and more descriptive labels +- navigation rail for medium and large layouts where a vertical persistent control fits the shell +- navigation bar for compact layouts and a small number of top-level destinations + +The current stories focus on drawer and rail, which are the forms implemented in this workspace today. + +## Accessibility semantics + +- Treat the container as a navigation landmark and provide a clear label when needed. +- Keep destination labels concise and unique. +- Expose active destination state consistently in routed integrations. + +## Story guide + +- [Navigation Drawer](?path=/story/components-m3navigation--navigation-drawer) +- [Navigation Rail](?path=/story/components-m3navigation--navigation-rail) +- [Modal Navigation Drawer](?path=/story/components-m3navigation--modal-navigation-drawer) + +## Usage guidance + +### Keep destination sets small and stable + +Top-level navigation should not behave like a task-specific filter group. If items appear and disappear unpredictably, users lose the mental map that navigation is supposed to provide. + +### Match pattern to layout + +Use drawer or rail based on screen width, information density, and shell persistence. Do not switch patterns arbitrarily if the content model itself has not changed. + +## Resources + +- [M3 Navigation bar overview](https://m3.material.io/components/navigation-bar/overview) +- [M3 Navigation rail overview](https://m3.material.io/components/navigation-rail/overview) +- [M3 Navigation drawer overview](https://m3.material.io/components/navigation-drawer/overview) +- [WAI-ARIA APG: Landmark Regions](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) diff --git a/m3-vue/storybook/components/M3Navigation.stories.ts b/m3-vue/storybook/components/M3Navigation.stories.ts index ca1419f..28b5779 100644 --- a/m3-vue/storybook/components/M3Navigation.stories.ts +++ b/m3-vue/storybook/components/M3Navigation.stories.ts @@ -135,3 +135,60 @@ export const NavigationRail: Story = { appearance: 'rail', }, } + +export const ModalNavigationDrawer: Story = { + // eslint-disable-next-line max-lines-per-function + render: (args: unknown) => ({ + name: 'M3ModalNavigationDrawerStory', + + components: { + M3Icon, + M3IconButton, + M3Navigation, + M3NavigationTab, + }, + + setup () { + return { + args, + expanded: ref(true), + } + }, + + template: ` + + + + + + + + + + + + + + + + + + `, + }), + + args: { + appearance: 'drawer', + }, +} diff --git a/m3-vue/storybook/components/M3RichTooltip.mdx b/m3-vue/storybook/components/M3RichTooltip.mdx index 8579073..ece4779 100644 --- a/m3-vue/storybook/components/M3RichTooltip.mdx +++ b/m3-vue/storybook/components/M3RichTooltip.mdx @@ -1,12 +1,82 @@ -import { Unstyled } from '@storybook/addon-docs/blocks' +import { Canvas, Meta } from '@storybook/addon-docs/blocks' +import * as M3RichTooltipStories from './M3RichTooltip.stories' -import Inline from './Inline' -import DeleteTooltip from '../examples/rich-tooltip/DeleteTooltip.vue' + # Rich tooltip - -
- -
-
+Rich tooltips provide contextual, supplementary information near a trigger element. Compared with plain tooltips, they support a slightly denser content payload while still staying lightweight and non-blocking. + +## API + +### When to use + +Use a rich tooltip for brief supporting information, shortcuts, or action consequences that help the user decide without forcing them into a dialog. If the content becomes critical or requires interaction-heavy confirmation, move to a larger surface. + +### Content limits + +Rich tooltips should stay short, contextual, and secondary: + +- one clear heading or intent +- one compact supporting explanation +- optional lightweight action or shortcut hint + +### Behavior + +The tooltip should remain attached to its trigger context and disappear when the user leaves that context. It should not become the only place where essential task information lives. + +## Accessibility semantics + +- Link trigger and tooltip with `aria-describedby`. +- Keep tooltip text concise and task-relevant. +- Avoid using tooltips as the only way to convey critical information. + +## Story guide + +- [Destructive Action](?path=/story/components-m3richtooltip--destructive-action) +- [Bulk Selection](?path=/story/components-m3richtooltip--bulk-selection) +- [Shortcut Hint](?path=/story/components-m3richtooltip--shortcut-hint) + +## Demo + + + + + +## Usage guidance + +### Avoid hidden requirements + +If the user must know the message to complete the task safely, do not hide it behind a tooltip. Surface it directly in the layout. + +### Keep interaction lightweight + +Rich tooltips can contain more than plain text, but they should still feel like supplementary context, not a mini dialog. + +## Content anatomy + +- short title or intent cue that helps users scan quickly +- one supporting sentence that explains the consequence, shortcut, or background +- optional trailing hint such as a keyboard shortcut or low-friction action + +The stories in this workspace keep the payload deliberately compact so the tooltip stays tethered to the trigger instead of competing with nearby layout. + +## Do / don't + +### Do + +- keep the message tightly tied to the hovered or focused trigger +- prefer consequence framing for risky actions and hint framing for shortcuts +- keep the tooltip short enough to read without shifting task focus + +### Don’t + +- move essential validation, permissions, or policy copy only into a tooltip +- stack multiple paragraphs or full form controls inside the surface +- use a rich tooltip when the user needs blocking confirmation or multi-step input + +## Resources + +- [M3 Tooltips overview](https://m3.material.io/components/tooltips/overview) +- [M3 Tooltips guidelines](https://m3.material.io/components/tooltips/guidelines) +- [WAI-ARIA APG: Tooltip Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/) diff --git a/m3-vue/storybook/components/M3RichTooltip.stories.ts b/m3-vue/storybook/components/M3RichTooltip.stories.ts new file mode 100644 index 0000000..abc1d1c --- /dev/null +++ b/m3-vue/storybook/components/M3RichTooltip.stories.ts @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import { M3RichTooltip } from '@/components/rich-tooltip' + +import DeleteTooltip from '../examples/rich-tooltip/DeleteTooltip.vue' +import SelectionTooltip from '../examples/rich-tooltip/SelectionTooltip.vue' +import ShortcutTooltip from '../examples/rich-tooltip/ShortcutTooltip.vue' + +const meta = { + title: 'Components/M3RichTooltip', + + component: M3RichTooltip, + + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const DestructiveAction: Story = { + render: () => ({ + components: { + DeleteTooltip, + }, + + template: '', + }), +} + +export const BulkSelection: Story = { + render: () => ({ + components: { + SelectionTooltip, + }, + + template: '', + }), +} + +export const ShortcutHint: Story = { + render: () => ({ + components: { + ShortcutTooltip, + }, + + template: '', + }), +} diff --git a/m3-vue/storybook/components/M3ScrollRail.mdx b/m3-vue/storybook/components/M3ScrollRail.mdx new file mode 100644 index 0000000..0748656 --- /dev/null +++ b/m3-vue/storybook/components/M3ScrollRail.mdx @@ -0,0 +1,52 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks' +import * as M3ScrollRailStories from './M3ScrollRail.stories' + + + +# Scroll rails + +`M3ScrollRail` is a supporting primitive for scrollable surfaces. It exposes the available scroll axis more clearly than relying on default browser chrome alone, especially inside custom containers. + +## API + +### When to use + +Use `M3ScrollRail` inside bounded regions such as drawers, side sheets, data panels, or media trays when the interface needs a clearer scroll affordance than the platform default provides. + +### Orientation + +The component can render vertical and horizontal rails. The combined story demonstrates both axes in one container, which is useful for wide content inside a constrained viewport. + +### Scope + +Material 3 does not publish a dedicated scroll-rail component page. In this workspace, the primitive supports scrollable M3 surfaces such as side sheets, drawers, and long content panes. + +## Accessibility semantics + +- Keep the scrollable region itself labelled when its purpose is not obvious from nearby context. +- Avoid trapping keyboard users in custom scroll containers. +- Preserve readable focus order inside the scrolling content. + +## Story guide + +- [Both](?path=/story/components-m3scrollrail--both) + +## Demo + + + +## Usage guidance + +### Use rails to reinforce, not replace, structure + +The scroll container still needs clear dimensions and hierarchy. A rail cannot compensate for a surface whose content model is already confusing. + +### Avoid nested scroll regions unless necessary + +Nested scrolling is hard to discover and harder to control with keyboard or touch input. Prefer one dominant scroll region when possible. + +## Resources + +- [M3 Side sheets overview](https://m3.material.io/components/side-sheets/overview) +- [M3 Navigation drawer overview](https://m3.material.io/components/navigation-drawer/overview) +- [M3 Menus overview](https://m3.material.io/components/menus/overview) diff --git a/m3-vue/storybook/components/M3ScrollRail.stories.ts b/m3-vue/storybook/components/M3ScrollRail.stories.ts index e871082..fccaa15 100644 --- a/m3-vue/storybook/components/M3ScrollRail.stories.ts +++ b/m3-vue/storybook/components/M3ScrollRail.stories.ts @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/vue3' import { M3ScrollRail } from '@/components/scroll-rail' +import { M3Surface } from '@/components/surface' const meta = { title: 'Components/M3ScrollRail', @@ -27,6 +28,7 @@ const meta = { components: { M3ScrollRail, + M3Surface, }, setup () { @@ -37,7 +39,14 @@ const meta = { }, template: ` -
+
-
+ `, }), diff --git a/m3-vue/storybook/components/M3Select.mdx b/m3-vue/storybook/components/M3Select.mdx index 134429c..6506185 100644 --- a/m3-vue/storybook/components/M3Select.mdx +++ b/m3-vue/storybook/components/M3Select.mdx @@ -1,8 +1,76 @@ -import { Meta } from '@storybook/addon-docs/blocks' +import { Canvas, Meta } from '@storybook/addon-docs/blocks' import * as M3SelectStories from './M3Select.stories' -# M3Select +# Select -Text field augmented with dropdown menu +`M3Select` combines text-field presentation with a menu-backed option picker. It fits cases where the current value should stay visible in the form while the available choices open in an anchored list. + +## API + +### When to use + +Use `M3Select` when users must choose one option from a structured list and the current choice should remain visible as field content. If freeform input is allowed, use a text field or autocomplete pattern instead. + +### Composition + +This implementation follows a hybrid pattern: + +- field-like trigger for label, outline, support text, and current value +- listbox-style option popup for selection +- optional leading content for selected value and option rows + +### Leading visuals + +The `WithIcons` story demonstrates a useful Material 3 pattern for country, account, or category pickers where a compact visual cue speeds up recognition without replacing the text label. + +## Accessibility semantics + +- `M3Select` exposes combobox semantics with a listbox popup and option items. +- Provide a visible label through `label` or an explicit aria-label/aria-labelledby strategy. +- Keep option labels unique and meaningful for screen readers. + +## Story guide + +- [Standard](?path=/story/components-m3select--standard) +- [With Icons](?path=/story/components-m3select--with-icons) +- [Outlined](?path=/story/components-m3select--outlined) +- [Invalid](?path=/story/components-m3select--invalid) + +## Demo + + + + + +## Usage guidance + +### Prefer stable option labels + +Because the chosen option is mirrored back into the field, label wording should stay concise and unambiguous. Long, repetitive labels make the closed state harder to scan. + +### Keep menus manageable + +If the option set becomes very large, consider search or autocomplete instead of a pure select interaction. + +## Code example + +```html + +``` + +## Resources + +- [M3 Text fields overview](https://m3.material.io/components/text-fields/overview) +- [M3 Menus overview](https://m3.material.io/components/menus/overview) +- [M3 Menus guidelines](https://m3.material.io/components/menus/guidelines) +- [WAI-ARIA APG: Combobox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) +- [WAI-ARIA APG: Listbox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) diff --git a/m3-vue/storybook/components/M3Select.stories.ts b/m3-vue/storybook/components/M3Select.stories.ts index f5288a5..0593983 100644 --- a/m3-vue/storybook/components/M3Select.stories.ts +++ b/m3-vue/storybook/components/M3Select.stories.ts @@ -130,3 +130,19 @@ export const WithIcons: Story = { `, }), } + +export const Outlined: Story = { + args: { + label: 'Choose', + outlined: true, + placeholder: 'Select an option', + }, +} + +export const Invalid: Story = { + args: { + label: 'Choose', + invalid: true, + placeholder: 'Required', + }, +} diff --git a/m3-vue/storybook/components/M3SideSheet.mdx b/m3-vue/storybook/components/M3SideSheet.mdx new file mode 100644 index 0000000..101dff7 --- /dev/null +++ b/m3-vue/storybook/components/M3SideSheet.mdx @@ -0,0 +1,84 @@ +import { Meta } from '@storybook/addon-docs/blocks' +import * as M3SideSheetStories from './M3SideSheet.stories' + + + +# Side Sheets + +Side sheets extend `M3Surface` into a right-edge panel with dedicated header, scrollable content area, and optional footer actions. + +## API + +### When to use + +Use `M3SideSheet` when the user needs supplemental context, filters, or secondary editing controls while keeping the main task visible. Compared with dialogs, side sheets are better for medium-density supporting content that still belongs to the current page. + +### Modes in this implementation + +- Standard mode opens as a modal sheet over the page. +- Docked mode keeps the sheet in layout as a persistent companion region. + +This follows the Material 3 distinction between temporary supporting surfaces and page-participating companion surfaces. + +### Content anatomy + +`M3SideSheet` gives you dedicated slots for: + +- `title` +- `close-icon` +- default body content +- `footer` + +The header and footer stay structurally distinct from the scrollable content region, so actions and dismiss affordances remain discoverable while the body changes. + +## Accessibility semantics + +- Use a clear title so the sheet can expose `aria-labelledby`. +- Keep close affordances discoverable through the close button and, in modal mode, the scrim. +- Reserve footer actions for final or high-signal decisions. +- Treat modal side sheets as interruptive overlays and docked sheets as companion layout regions. + +## Stories + +- [Standard](?path=/story/components-m3sidesheet--standard) +- [Docked](?path=/story/components-m3sidesheet--docked) + +## Usage guidance + +### Standard vs docked + +Choose `docked` when the page should continuously reserve space for the sheet and the surface is part of the working layout. Choose modal mode when the same content should feel temporary, dismissible, and layered above the page. + +### Footer usage + +Use the footer for apply, save, or clear actions that summarize the sheet's purpose. Avoid turning the footer into a second content area; supporting explanation belongs in the body. + +### Dismissal + +In modal mode, users should be able to close the sheet from the close icon and by leaving the sheet context. In docked mode, think of hide/show as a layout state change rather than as a modal dismissal. + +## Code example + +```html + + + + + +

Choose filters and apply changes.

+ + +
+``` + +## Resources + +- [M3 Side sheets overview](https://m3.material.io/components/side-sheets/overview) +- [M3 Side sheets guidelines](https://m3.material.io/components/side-sheets/guidelines) +- [WAI-ARIA APG: Dialog (Modal) Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) diff --git a/m3-vue/storybook/components/M3SideSheet.stories.ts b/m3-vue/storybook/components/M3SideSheet.stories.ts new file mode 100644 index 0000000..347f32f --- /dev/null +++ b/m3-vue/storybook/components/M3SideSheet.stories.ts @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/vue3' + +import { M3Button } from '@/components/button' +import { M3Icon } from '@/components/icon' +import { M3SideSheet } from '@/components/side-sheet' + +import { ref } from 'vue' + +const sideSheetStoryTemplate = ` + + Open side sheet + + + + + + + +

Choose filters and apply changes.

+ + +
+` + +const meta = { + title: 'Components/M3SideSheet', + + component: M3SideSheet, + + argTypes: { + shown: { + control: false, + }, + }, + + args: { + docked: false, + }, + + render: (args: unknown) => ({ + components: { + M3Button, + M3Icon, + M3SideSheet, + }, + + setup () { + const shown = ref(false) + + return { + args, + shown, + } + }, + + template: sideSheetStoryTemplate, + }), + + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Standard: Story = {} + +export const Docked: Story = { + args: { + docked: true, + }, +} diff --git a/m3-vue/storybook/components/M3Slider.mdx b/m3-vue/storybook/components/M3Slider.mdx index 8c0592f..c27674c 100644 --- a/m3-vue/storybook/components/M3Slider.mdx +++ b/m3-vue/storybook/components/M3Slider.mdx @@ -1,8 +1,59 @@ -import { Meta } from '@storybook/addon-docs/blocks' +import { Canvas, Meta } from '@storybook/addon-docs/blocks' import * as M3SliderStories from './M3Slider.stories' -# M3Slider +# Sliders -Sliders let users make selections from a range of values +Sliders let users select a value or bounded range with direct manipulation. Material 3 treats them as a good fit for continuous or near-continuous scales where people benefit from seeing relative position. + +## API + +### When to use + +Use `M3Slider` for ranges such as volume, intensity, zoom, or budget bounds. If users need precise text entry or long discrete lists, a text field or select is often clearer. + +### Single vs range + +- single slider for one value +- range slider when users define lower and upper bounds together + +### Range communication + +Range sliders work best when both handles affect the same conceptual value space and when users can understand the minimum and maximum without extra explanation. + +## Accessibility semantics + +- Each slider handle must have an accessible name (`ariaHandle`, `ariaHandleMin`, `ariaHandleMax`). +- Keep numeric ranges and steps predictable and documented for users. +- Support keyboard adjustments (Arrow, Home, End) for all handles. + +## Story guide + +- [Single](?path=/story/components-m3slider--single) +- [Range](?path=/story/components-m3slider--range) +- [Discrete Single](?path=/story/components-m3slider--discrete-single) +- [Disabled Range](?path=/story/components-m3slider--disabled-range) + +## Demo + + + + + +## Usage guidance + +### Make scale meaning explicit + +Users should understand what the endpoints represent before they drag. Labels, units, or support text usually matter more than the slider control itself. + +### Avoid sliders for exact data entry + +If the task depends on precise numeric values, provide an exact-input companion or use a different control entirely. + +## Resources + +- [M3 Sliders overview](https://m3.material.io/components/sliders/overview) +- [M3 Sliders guidelines](https://m3.material.io/components/sliders/guidelines) +- [WAI-ARIA APG: Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) +- [WAI-ARIA APG: Multi-Thumb Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb/) diff --git a/m3-vue/storybook/components/M3Slider.stories.ts b/m3-vue/storybook/components/M3Slider.stories.ts index 29aacd0..d3eecd6 100644 --- a/m3-vue/storybook/components/M3Slider.stories.ts +++ b/m3-vue/storybook/components/M3Slider.stories.ts @@ -82,3 +82,21 @@ export const Range: Story = { type: 'range', }, } + +export const DiscreteSingle: Story = { + args: { + type: 'single', + step: 10, + ariaHandle: { + label: 'Volume', + }, + ariaHandleMax: {}, + }, +} + +export const DisabledRange: Story = { + args: { + type: 'range', + disabled: true, + }, +} diff --git a/m3-vue/storybook/components/M3Surface.mdx b/m3-vue/storybook/components/M3Surface.mdx new file mode 100644 index 0000000..f67d293 --- /dev/null +++ b/m3-vue/storybook/components/M3Surface.mdx @@ -0,0 +1,106 @@ +import { Meta } from '@storybook/addon-docs/blocks' +import * as M3SurfaceStories from './M3Surface.stories' + + + +# Surfaces + +Use `M3SurfacePanel` for static decorative surfaces such as cards, docs wrappers, and nested surface blocks. Reserve `M3Surface` for modal behavior and richer container orchestration. + +## API + +### Surface roles + +This workspace separates two responsibilities that are often mixed in product code: + +- `M3SurfacePanel` for local, in-flow containers +- `M3Surface` for modal positioning, scrim management, anchoring, and richer surface choreography + +This mirrors Material 3 guidance, where the same visual surface language is reused across cards, dialogs, side sheets, and other temporary containers, but the interaction model changes by context. + +### Variant and elevation model + +Use `variant` when the surface role is explicit. Use `auto` only when you intentionally want elevation to resolve the surface tone automatically. The orchestration stories in this package mostly choose explicit variants, because transitions between states should keep semantics stable while only geometry and modality change. + +### Modal behavior + +`M3Surface` handles: + +- modal teleporting +- scrim fade +- anchor positioning +- rounded-corner transitions +- size and inset transitions for morph scenarios + +The component does not prescribe content anatomy. Dialogs, side sheets, and custom orchestrated surfaces build that structure on top. + +## Story guide + +- [Side Sheet Docked To Modal](?path=/story/components-m3surface--side-sheet-docked-to-modal) + Shows how a docked layout participant becomes a modal overlay and returns to the reserved host without snapping. +- [Card Replacing Page](?path=/story/components-m3surface--card-replacing-page) + Demonstrates a compact card expanding into a page-like work area while preserving reserved layout zones. +- [Side Sheet Modal Dismiss Removes Surface](?path=/story/components-m3surface--side-sheet-modal-dismiss-removes-surface) + Covers the branch where modal dismissal removes the feature surface instead of restoring a docked state. +- [Side Sheet Always Modal Toggle](?path=/story/components-m3surface--side-sheet-always-modal-toggle) + Keeps the same surface always modal and focuses only on repeated entry and dismissal. +- [Side Sheet Modal To Window](?path=/story/components-m3surface--side-sheet-modal-to-window) + Morphs a right-edge modal sheet into a centered dialog window while keeping one modal surface vocabulary. +- [Nested Dialogs Chain](?path=/story/components-m3surface--nested-dialogs-chain) + Demonstrates layered modal surfaces, topmost scrim ownership, and per-layer enter/exit motion. +- [Workspace Modal Dialog](?path=/story/components-m3surface--workspace-modal-dialog) + Shows a centered blocking decision surface in a realistic workspace scene without morph choreography. +- [Inspector Side Sheet](?path=/story/components-m3surface--inspector-side-sheet) + Demonstrates a practical supporting-edit sheet anchored to the edge of a dashboard. + +## Usage guidance + +### Prefer the narrowest abstraction + +Reach for `M3SurfacePanel` when the surface only needs tone, radius, elevation, and local sizing. Move to `M3Surface` only when the container becomes modal, anchored, or transition-driven. + +### Keep state names explicit + +The orchestration examples work best when you model stable states such as `docked`, `modal-sheet`, or `window`, and then derive geometry and motion from those states rather than from ad hoc booleans. + +### Match motion to target geometry + +When morphing between surfaces, animate toward a measured target rectangle or target inset set. This prevents the “overlay shrinks somewhere else, then snaps” failure mode that surface transitions are especially sensitive to. + +## Content anatomy + +### Dialog-like surface + +- headline or short title that explains the decision +- compact supporting copy that clarifies consequences +- primary and secondary actions grouped near the end of the reading flow + +### Side-sheet surface + +- header with title and optional affordance action +- scrollable supporting content or form body +- footer actions anchored at the bottom when the sheet remains open during editing + +The `Inspector Side Sheet` story shows this anatomy with the standard `m3-side-sheet__header`, `m3-side-sheet__content`, and `m3-side-sheet__footer` structure layered on top of `M3Surface`. + +## Do / don't + +### Do + +- keep supporting surfaces narrowly scoped to one nearby task +- preserve surrounding context for side sheets and supporting panels +- align action placement with the surface role, such as bottom-aligned footer actions for longer sheets +- use explicit stable states when one surface can morph into another + +### Don’t + +- replace a full workflow with a side sheet if the user needs a primary destination +- overload modal surfaces with dense navigation or unrelated secondary tools +- let transition targets drift away from the final resting geometry +- hide critical task instructions only inside a temporary supporting surface + +## Resources + +- [M3 Cards overview](https://m3.material.io/components/cards/overview) +- [M3 Dialogs overview](https://m3.material.io/components/dialogs/overview) +- [M3 Side sheets overview](https://m3.material.io/components/side-sheets/overview) diff --git a/m3-vue/storybook/components/M3Surface.stories.ts b/m3-vue/storybook/components/M3Surface.stories.ts new file mode 100644 index 0000000..c33ae18 --- /dev/null +++ b/m3-vue/storybook/components/M3Surface.stories.ts @@ -0,0 +1,124 @@ +import type { + Meta, + StoryObj, +} from '@storybook/vue3' + +import M3Surface from '@/components/surface/M3Surface.vue' + +import SurfaceCardPageMorph from '../examples/surface/SurfaceCardPageMorph.vue' +import SurfaceInspectorSheet from '../examples/surface/SurfaceInspectorSheet.vue' +import SurfaceNestedDialogsChain from '../examples/surface/SurfaceNestedDialogsChain.vue' +import SurfaceSideSheetAlwaysModal from '../examples/surface/SurfaceSideSheetAlwaysModal.vue' +import SurfaceSideSheetDismissToRemove from '../examples/surface/SurfaceSideSheetDismissToRemove.vue' +import SurfaceSideSheetModalToWindow from '../examples/surface/SurfaceSideSheetModalToWindow.vue' +import SurfaceSideSheetMorph from '../examples/surface/SurfaceSideSheetMorph.vue' +import SurfaceWorkspaceDialog from '../examples/surface/SurfaceWorkspaceDialog.vue' + +const meta = { + title: 'Components/M3Surface', + component: M3Surface, + + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const SideSheetDockedToModal: Story = { + render: () => ({ + components: { + SurfaceSideSheetMorph, + }, + + template: ` + + `, + }), +} + +export const CardReplacingPage: Story = { + render: () => ({ + components: { + SurfaceCardPageMorph, + }, + + template: ` + + `, + }), +} + +export const SideSheetModalDismissRemovesSurface: Story = { + render: () => ({ + components: { + SurfaceSideSheetDismissToRemove, + }, + + template: ` + + `, + }), +} + +export const SideSheetAlwaysModalToggle: Story = { + render: () => ({ + components: { + SurfaceSideSheetAlwaysModal, + }, + + template: ` + + `, + }), +} + +export const SideSheetModalToWindow: Story = { + render: () => ({ + components: { + SurfaceSideSheetModalToWindow, + }, + + template: ` + + `, + }), +} + +export const NestedDialogsChain: Story = { + render: () => ({ + components: { + SurfaceNestedDialogsChain, + }, + + template: ` + + `, + }), +} + +export const WorkspaceModalDialog: Story = { + render: () => ({ + components: { + SurfaceWorkspaceDialog, + }, + + template: ` + + `, + }), +} + +export const InspectorSideSheet: Story = { + render: () => ({ + components: { + SurfaceInspectorSheet, + }, + + template: ` + + `, + }), +} diff --git a/m3-vue/storybook/components/M3Switch.mdx b/m3-vue/storybook/components/M3Switch.mdx index 0e22f38..4f5e389 100644 --- a/m3-vue/storybook/components/M3Switch.mdx +++ b/m3-vue/storybook/components/M3Switch.mdx @@ -8,26 +8,52 @@ import * as M3SwitchStories from './M3Switch.stories' # Switch - - Switches toggle the selection of an item on or off +Switches communicate an immediate on/off state. They are best for settings and independent toggles where users expect the state to change as soon as they interact. - * Use switches (not radio buttons) if the items in a list can be independently controlled - * Switches are the best way to let users adjust settings - * Make sure the switch’s selection (on or off) is visible at a glance - +## API + +### When to use + +Use `M3Switch` for binary system or preference settings such as Wi-Fi, notifications, or sync. If the choice needs confirmation or belongs to a form submitted later, a checkbox may communicate intent more clearly. + +### Immediate behavior + +Material 3 switches imply direct state change. The control should reflect the new state right away and the surrounding UI should treat it as active unless the flow explicitly communicates pending sync or save. + +## Accessibility semantics + +- Keep a visible label next to each switch control. +- Expose checked state via `role="switch"` and `aria-checked`. +- Use switches for immediate on/off state changes, not for multi-choice selections. ### Demo -
+
-### Resources +## Story guide - - * [Guidelines](https://m3.material.io/components/switch/guidelines) - * [Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) - +- [Standard Switch](?path=/story/components-m3switch--standard-switch) + +## Usage guidance + +### Write labels as current setting names + +Prefer labels like "Wi-Fi" or "Auto-update" over imperative wording like "Enable Wi-Fi." The state of the switch already communicates on/off. + +### Avoid switches for irreversible or risky actions + +If toggling the control can delete data or trigger destructive side effects, use a safer pattern with clearer confirmation. + +## Resources + +- [M3 Switch overview](https://m3.material.io/components/switch/overview) +- [M3 Switch guidelines](https://m3.material.io/components/switch/guidelines) +- [WAI-ARIA APG: Switch Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) diff --git a/m3-vue/storybook/components/M3TextField.mdx b/m3-vue/storybook/components/M3TextField.mdx index 0fdf76c..718eb66 100644 --- a/m3-vue/storybook/components/M3TextField.mdx +++ b/m3-vue/storybook/components/M3TextField.mdx @@ -12,9 +12,32 @@ import { M3TextField } from '@/components/text-field' # Text fields -Text fields let users enter text into a UI +Text fields capture short or medium-length textual input. Material 3 uses them as a flexible foundation for plain entry, assisted entry, password input, and multiline editing. -[Design Kit (Figma)](https://www.figma.com/community/file/1035203688168086460) +## API + +### When to use + +Use `M3TextField` for direct text entry, searchable fields, passwords, and compact multiline notes. If the value must come from a predefined list, prefer `M3Select` or autocomplete patterns instead of asking users to type and validate manually. + +### Common forms + +This workspace supports several Material 3 text-field patterns: + +- filled and outlined presentation +- leading icons +- password fields +- multiline input + +### Support text and validation + +Labels, support text, and validation messages should work together. The field alone rarely communicates enough context for successful data entry. + +## Accessibility semantics + +- Provide a persistent text label (`label` prop or explicit aria-labelledby). +- Use `aria-invalid` together with helper/support text for validation feedback. +- For long-form input, prefer multiline mode (`textarea`) with the same labeling strategy. + +## Story guide + +- [Text Field](?path=/story/components-m3textfield--text-field) +- [Password Field](?path=/story/components-m3textfield--password-field) +- [Outlined With Leading Icon](?path=/story/components-m3textfield--outlined-with-leading-icon) +- [Multiline Outlined](?path=/story/components-m3textfield--multiline-outlined) + +## Usage guidance + +### Keep labels persistent + +A placeholder is not a replacement for a label. Users need the field purpose to stay visible while typing and when returning to the form later. + +### Match type to expected content + +Use semantic input types like `email`, `tel`, or `password` whenever possible so browsers and assistive technologies can help users enter data correctly. + +## Code example + +```html + +``` + +## Resources + +- [M3 Text fields overview](https://m3.material.io/components/text-fields/overview) +- [M3 Text fields guidelines](https://m3.material.io/components/text-fields/guidelines) +- [WAI Tutorials: Form Labels](https://www.w3.org/WAI/tutorials/forms/labels/) diff --git a/m3-vue/storybook/components/M3TextField.stories.ts b/m3-vue/storybook/components/M3TextField.stories.ts index 93a74fe..1e77d41 100644 --- a/m3-vue/storybook/components/M3TextField.stories.ts +++ b/m3-vue/storybook/components/M3TextField.stories.ts @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/vue3' +import { M3Icon } from '@/components/icon' import { M3TextField } from '@/components/text-field' import { ref } from 'vue' @@ -68,3 +69,46 @@ export const PasswordField: Story = { label: 'Password field', }, } + +export const OutlinedWithLeadingIcon: Story = { + args: { + type: 'email', + label: 'Email', + outlined: true, + placeholder: 'name@example.com', + }, + + render: (args: unknown) => ({ + components: { + M3Icon, + M3TextField, + }, + + setup () { + return { + args, + value: ref(''), + } + }, + + template: ` + + + + `, + }), +} + +export const MultilineOutlined: Story = { + args: { + label: 'About', + outlined: true, + multiline: true, + placeholder: 'Add a short summary', + }, +} diff --git a/m3-vue/storybook/examples/checkbox/CheckboxList.vue b/m3-vue/storybook/examples/checkbox/CheckboxList.vue index 382f534..530f650 100644 --- a/m3-vue/storybook/examples/checkbox/CheckboxList.vue +++ b/m3-vue/storybook/examples/checkbox/CheckboxList.vue @@ -1,5 +1,12 @@