mirror of
https://github.com/navikt/aksel.git
synced 2026-10-13 17:22:02 +00:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6324d63745 | ||
|
|
debfb3fd15 | ||
|
|
a3e8457baf | ||
|
|
0560f84acd | ||
|
|
663dfbe8a4 | ||
|
|
536f088b97 | ||
|
|
f659599787 | ||
|
|
ed34bc034a | ||
|
|
2a2028fd95 | ||
|
|
386145d63a | ||
|
|
1ed809c872 | ||
|
|
e2359ddf33 | ||
|
|
069e3346ec | ||
|
|
c941af9de5 | ||
|
|
a273a22172 | ||
|
|
0e94031f1e | ||
|
|
64a9762286 | ||
|
|
a05dbadfa4 | ||
|
|
7c468b18d1 | ||
|
|
cfcd78a450 | ||
|
|
9e677697a7 | ||
|
|
8a8bf0a274 | ||
|
|
1e40d96510 | ||
|
|
db56141b2e | ||
|
|
ec351ba27f | ||
|
|
2e98a09540 | ||
|
|
2d438e1d37 | ||
|
|
8b373e8b80 | ||
|
|
179eb51221 | ||
|
|
d98624618c | ||
|
|
ecc501a0a8 | ||
|
|
fec3b12d5f | ||
|
|
8f99d3a102 | ||
|
|
7fa7719c24 | ||
|
|
bc1b9fdad9 | ||
|
|
755d75903a | ||
|
|
5ebc71470a | ||
|
|
17daddab86 | ||
|
|
20ced9b084 | ||
|
|
6d2115cdc9 | ||
|
|
17ae19c5fe | ||
|
|
a60298f232 | ||
|
|
5866992e7d | ||
|
|
8ab241a980 | ||
|
|
363d76b9b2 | ||
|
|
5da970fd62 | ||
|
|
296e43cab2 | ||
|
|
85fec974dc | ||
|
|
6ddea4eaf2 | ||
|
|
acea9ebb78 | ||
|
|
fdbd634012 | ||
|
|
dcf5452d28 | ||
|
|
0a8c33bbbe | ||
|
|
18b0cf5fa5 | ||
|
|
374a679bc3 |
No files matched your search
+26
-12
@@ -1,4 +1,11 @@
|
||||
// TODO: Convert to new format: https://eslint.org/docs/latest/use/configure/migration-guide
|
||||
/**
|
||||
* TODO:
|
||||
* - Convert to new format (v9): https://eslint.org/docs/latest/use/configure/migration-guide
|
||||
* - Look into opinionated stylistic plugin
|
||||
* - Replace @typescript-eslint/recommended with @typescript-eslint/recommended-type-checked
|
||||
* - We should add "plugin:react/jsx-runtime", but not sure if this will cause problems for projects not using the new JSX transform
|
||||
* - Consider adding the rule "id-length"
|
||||
*/
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
@@ -8,15 +15,13 @@ module.exports = {
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
//"plugin:react/jsx-runtime", // Not sure if this will cause problems for projects not using the new JSX transform
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:storybook/recommended",
|
||||
// TODO: Look into opinionated stylistic plugin
|
||||
// TODO: Replace @typescript-eslint/recommended with @typescript-eslint/recommended-type-checked
|
||||
],
|
||||
/* Allows eslint-plugin-react to detect installed react-version */
|
||||
settings: { react: { version: "detect" } },
|
||||
rules: {
|
||||
"import/no-unresolved": "off",
|
||||
@@ -26,12 +31,6 @@ module.exports = {
|
||||
],
|
||||
"array-callback-return": "error",
|
||||
"object-shorthand": "error",
|
||||
// TODO: Consider adding the rule "id-length"
|
||||
// Temporary:
|
||||
"react/prop-types": "off",
|
||||
"react/display-name": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
// "import/no-cycle": [1],
|
||||
"no-else-return": "error",
|
||||
"no-console": [
|
||||
"warn",
|
||||
@@ -53,7 +52,12 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
],
|
||||
/* Temporarily turned off until code is updated */
|
||||
"react/prop-types": "off",
|
||||
"react/display-name": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
},
|
||||
/* https://eslint.org/docs/latest/use/configure/rules#report-unused-eslint-disable-comments */
|
||||
reportUnusedDisableDirectives: true,
|
||||
overrides: [
|
||||
{
|
||||
@@ -66,17 +70,27 @@ module.exports = {
|
||||
files: ["**/*.ts?(x)"],
|
||||
extends: ["plugin:@typescript-eslint/recommended"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-shadow": ["error", { hoist: "all" }], // TODO: Consider { builtinGlobals: true }
|
||||
/*
|
||||
* TODO:
|
||||
* - Consider { builtinGlobals: true }
|
||||
*/
|
||||
"@typescript-eslint/no-shadow": ["error", { hoist: "all" }],
|
||||
"@typescript-eslint/no-explicit-any": "off", // Temporary
|
||||
"@typescript-eslint/array-type": "error",
|
||||
"@typescript-eslint/no-unused-expressions": [
|
||||
"error",
|
||||
/* https://eslint.org/docs/latest/rules/no-unused-expressions#allowshortcircuit-and-allowternary */
|
||||
{ allowShortCircuit: true, allowTernary: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.test.*", "**/__tests__/*"],
|
||||
plugins: ["@vitest"],
|
||||
extends: [
|
||||
"plugin:@vitest/legacy-recommended",
|
||||
"plugin:testing-library/react",
|
||||
/* Needed to support v8 eslint config-syntax */
|
||||
"plugin:@vitest/legacy-recommended",
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.35.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.48.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
|
||||
@@ -136,34 +136,34 @@ import Setup from "./assets/setup.png";
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-gap: var(--a-spacing-6);
|
||||
grid-gap: 1.5rem;
|
||||
}
|
||||
|
||||
.sb-link{
|
||||
text-decoration: underline;
|
||||
color: var(--a-text-default);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
padding-bottom: var(--a-spacing-5);
|
||||
padding-bottom: 1.25rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sb-section-title {
|
||||
margin-bottom: var(--a-spacing-8);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.sb-section-item-heading {
|
||||
padding-bottom: var(--a-spacing-1);
|
||||
padding-bottom: 0.25rem
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.sb-section-item-paragraph {
|
||||
margin: 0;
|
||||
padding-bottom: var(--a-spacing-3);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
|
||||
@@ -53,6 +53,7 @@ export default {
|
||||
"@storybook/addon-a11y",
|
||||
"@whitespace/storybook-addon-html",
|
||||
"@storybook/addon-interactions",
|
||||
"@storybook/addon-themes",
|
||||
{
|
||||
name: "@storybook/addon-storysource",
|
||||
options: {
|
||||
|
||||
+51
-30
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import "@navikt/ds-css/index.css";
|
||||
import { withThemeByClassName } from "@storybook/addon-themes";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
import darksideCss from "@navikt/ds-css/darkside/index.css?inline";
|
||||
import defaultCss from "@navikt/ds-css/index.css?inline";
|
||||
import "./layout.css";
|
||||
|
||||
export const parameters = {
|
||||
@@ -16,44 +18,63 @@ export const parameters = {
|
||||
},
|
||||
},
|
||||
layout: "centered",
|
||||
backgrounds: {
|
||||
values: [
|
||||
{
|
||||
name: "Canvas",
|
||||
value: "#ffffff",
|
||||
},
|
||||
{
|
||||
name: "Darkmode",
|
||||
value: "#23262a",
|
||||
},
|
||||
],
|
||||
},
|
||||
backgrounds: { disable: true },
|
||||
};
|
||||
|
||||
export const globalTypes = {
|
||||
theme: {
|
||||
name: "Theme",
|
||||
mode: {
|
||||
name: "Darkside",
|
||||
toolbar: {
|
||||
icon: "circlehollow",
|
||||
items: [
|
||||
{ value: "light", icon: "circlehollow", title: "light" },
|
||||
{ value: "dark", icon: "circle", title: "dark" },
|
||||
{
|
||||
value: "default",
|
||||
icon: "hourglass",
|
||||
title: "Default (old)",
|
||||
},
|
||||
{ value: "darkside", icon: "beaker", title: "Darkside (new)" },
|
||||
],
|
||||
showName: true,
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const withTheme = (StoryFn, context) => {
|
||||
return (
|
||||
<div
|
||||
data-theme={context.parameters.theme || context.globals.theme || "light"}
|
||||
lang="no"
|
||||
id="root"
|
||||
>
|
||||
<StoryFn />
|
||||
</div>
|
||||
);
|
||||
export const initialGlobals = {
|
||||
mode: "default",
|
||||
};
|
||||
|
||||
export const decorators = [withTheme];
|
||||
const ModeDecorator = ({ children, mode }) => {
|
||||
useLayoutEffect(() => {
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = mode === "darkside" ? darksideCss : defaultCss;
|
||||
document.head.appendChild(style);
|
||||
|
||||
if (mode === "darkside") {
|
||||
document.body.style.setProperty("background", "var(--a-bg-default)");
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
document.body.style.removeProperty("background");
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export const decorators = [
|
||||
withThemeByClassName({
|
||||
themes: {
|
||||
light: "light",
|
||||
dark: "dark",
|
||||
},
|
||||
defaultTheme: "light",
|
||||
}),
|
||||
(StoryFn, context) => {
|
||||
return (
|
||||
<ModeDecorator mode={context.globals.mode}>
|
||||
<StoryFn />
|
||||
</ModeDecorator>
|
||||
);
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,9 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 7.3.0
|
||||
|
||||
## 7.2.1
|
||||
|
||||
## 7.2.0
|
||||
|
||||
## 7.1.2
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { splitIntoParts } from "../config/figma/fetch-icons";
|
||||
|
||||
describe("splitIntoParts", () => {
|
||||
test("should split array into parts correctly with odd count array", () => {
|
||||
const arr = ["a", "b", "c", "d", "e"];
|
||||
const result = splitIntoParts(arr, 4);
|
||||
expect(result).toEqual(["a,b", "c,d", "e"]);
|
||||
});
|
||||
|
||||
test("should split array into parts correctly with even count array", () => {
|
||||
const arr = ["a", "b", "c", "d", "e", "f"];
|
||||
const result = splitIntoParts(arr, 4);
|
||||
expect(result).toEqual(["a,b", "c,d", "e,f"]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
/* eslint-disable import/no-named-as-default-member */
|
||||
import fastglob from "fast-glob";
|
||||
import fs from "fs";
|
||||
import jsYaml from "js-yaml";
|
||||
import { JSON_SCHEMA, load } from "js-yaml";
|
||||
import path from "path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { IconYml } from "../config/figma/make-configs";
|
||||
|
||||
const basePath = path.resolve(__dirname, "../icons");
|
||||
|
||||
@@ -14,9 +14,10 @@ const ymlList = fastglob
|
||||
describe(`Each icons YML-config is valid`, () => {
|
||||
ymlList.forEach((file) => {
|
||||
test(`${file} has valid YML-config`, () => {
|
||||
const ymlData = jsYaml.load(fs.readFileSync(`${basePath}/${file}`), {
|
||||
schema: jsYaml.JSON_SCHEMA,
|
||||
});
|
||||
const ymlData = load(fs.readFileSync(`${basePath}/${file}`).toString(), {
|
||||
schema: JSON_SCHEMA,
|
||||
}) as IconYml;
|
||||
|
||||
expect(ymlData.name).toBeTruthy();
|
||||
expect(ymlData.category).toBeTruthy();
|
||||
expect(ymlData.sub_category).toBeTruthy();
|
||||
@@ -31,7 +32,7 @@ describe(`Each icons YML-config is valid`, () => {
|
||||
});
|
||||
});
|
||||
|
||||
function isDate(dateStr) {
|
||||
function isDate(dateStr: string) {
|
||||
return !Number.isNaN(
|
||||
new Date(dateStr.split(".").reverse().join(".")).getDate(),
|
||||
);
|
||||
|
||||
@@ -21,8 +21,7 @@ describe(`Each icons has a valid code`, () => {
|
||||
|
||||
test(`has valid attributes on root-node`, () => {
|
||||
const root = select(":root", iconAst);
|
||||
const properties =
|
||||
root && root.properties ? Object.keys(root.properties).sort() : [];
|
||||
const properties = Object.keys(root?.properties ?? []).sort();
|
||||
|
||||
expect(properties).toStrictEqual(
|
||||
["viewBox", "xmlns", "height", "width", "fill"].sort(),
|
||||
@@ -31,45 +30,41 @@ describe(`Each icons has a valid code`, () => {
|
||||
|
||||
test(`has valid xml-attr`, () => {
|
||||
const root = select(":root", iconAst);
|
||||
const xmlns = root && root.properties ? root.properties.xmlns : null;
|
||||
expect(xmlns).toBe("http://www.w3.org/2000/svg");
|
||||
expect(root?.properties?.xmlns).toBe("http://www.w3.org/2000/svg");
|
||||
});
|
||||
|
||||
test(`has valid viewbox`, () => {
|
||||
const root = select(":root", iconAst);
|
||||
const viewbox =
|
||||
root && root.properties ? root.properties.viewBox : null;
|
||||
expect(viewbox).toBe("0 0 24 24");
|
||||
expect(root?.properties?.viewBox).toBe("0 0 24 24");
|
||||
});
|
||||
|
||||
test(`root fill is none`, () => {
|
||||
const root = select(":root", iconAst);
|
||||
const fill = root && root.properties ? root.properties.fill : null;
|
||||
expect(fill).toBe("none");
|
||||
expect(root?.properties?.fill).toBe("none");
|
||||
});
|
||||
|
||||
test(`has valid width and height`, () => {
|
||||
const root = select(":root", iconAst);
|
||||
const width = root && root.properties ? root.properties.width : null;
|
||||
const height = root && root.properties ? root.properties.height : null;
|
||||
expect(width).toBe("24");
|
||||
expect(height).toBe("24");
|
||||
expect(root?.properties?.width).toBe("24");
|
||||
expect(root?.properties?.height).toBe("24");
|
||||
});
|
||||
|
||||
test(`has valid stroke`, () => {
|
||||
const nodes = selectAll("*", iconAst);
|
||||
|
||||
nodes.forEach((n) => {
|
||||
n.properties?.stroke &&
|
||||
if (n.properties?.stroke) {
|
||||
expect(n.properties.stroke).toEqual("#23262A");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`has valid stroke-width`, () => {
|
||||
const nodes = selectAll("*", iconAst);
|
||||
nodes.forEach((n) => {
|
||||
n.properties?.strokeWidth &&
|
||||
if (n.properties?.strokeWidth) {
|
||||
expect(n.properties?.strokeWidth).toEqual("1.5");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
const file_key = "wEdyFjCQSBR3U7FvrMbPXa";
|
||||
|
||||
export const fetchIcons = async () => {
|
||||
console.info("Fetching new icons from Figma...");
|
||||
const data = await fetch(
|
||||
`https://api.figma.com/v1/files/${file_key}/components`,
|
||||
{
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
"X-FIGMA-TOKEN": process.env.FIGMA_TOKEN,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.catch((e) => {
|
||||
throw e.message;
|
||||
});
|
||||
|
||||
if (data?.err) {
|
||||
throw new Error(data.err);
|
||||
}
|
||||
|
||||
if (!data?.meta?.components || data?.meta?.components.length === 0) {
|
||||
throw new Error("Fant ingen publiserte ikoner");
|
||||
}
|
||||
|
||||
return data?.meta?.components;
|
||||
};
|
||||
|
||||
export const fetchDownloadUrls = async (idArray) => {
|
||||
const urls = { images: null };
|
||||
|
||||
for (let ids of idArray) {
|
||||
await fetch(
|
||||
`https://api.figma.com/v1/images/${file_key}/?ids=${ids}&format=svg`,
|
||||
{
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
"X-FIGMA-TOKEN": process.env.FIGMA_TOKEN,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
urls.images = {
|
||||
...urls.images,
|
||||
...x.images,
|
||||
};
|
||||
})
|
||||
.catch((e) => {
|
||||
throw e.message;
|
||||
});
|
||||
}
|
||||
return urls;
|
||||
};
|
||||
|
||||
export const fetchIcon = async (url) => {
|
||||
return await fetch(url)
|
||||
.then((x) => x.text())
|
||||
.catch((e) => {
|
||||
throw e.message;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
BadRequestErrorResponseWithErrMessage,
|
||||
ErrorResponsePayloadWithErrMessage,
|
||||
GetFileComponentsResponse,
|
||||
GetImagesResponse,
|
||||
PublishedComponent,
|
||||
} from "@figma/rest-api-spec";
|
||||
|
||||
/* https://www.figma.com/developers/api#library-items */
|
||||
const FIGMA_FILE_KEY = "wEdyFjCQSBR3U7FvrMbPXa";
|
||||
|
||||
export const fetchIcons = async (): Promise<PublishedComponent[]> => {
|
||||
console.group("Fetching list of published icons from Figma...");
|
||||
const data:
|
||||
| GetFileComponentsResponse
|
||||
| BadRequestErrorResponseWithErrMessage = await fetch(
|
||||
`https://api.figma.com/v1/files/${FIGMA_FILE_KEY}/components`,
|
||||
{
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
"X-FIGMA-TOKEN": process.env.FIGMA_TOKEN ?? "",
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.catch((e) => {
|
||||
throw e.message;
|
||||
});
|
||||
|
||||
if (data.status !== 200) {
|
||||
throw new Error(data.err);
|
||||
}
|
||||
|
||||
if (data.meta.components.length === 0) {
|
||||
throw new Error("No published icons found");
|
||||
}
|
||||
|
||||
/* Filtes out any 'random' icons not meant for the icon-library */
|
||||
const validIcons = data.meta.components.filter((icon) => {
|
||||
const validIcon =
|
||||
!!icon.containing_frame?.pageName && !!icon.containing_frame?.name;
|
||||
|
||||
if (!validIcon) {
|
||||
console.warn(`Ignoring icon '${icon.name}' as it is missing metadata`);
|
||||
}
|
||||
return validIcon;
|
||||
});
|
||||
|
||||
console.info(
|
||||
`Found ${validIcons.length} published icons in Figma. ${
|
||||
data.meta.components.length - validIcons.length
|
||||
} icons were ignored due to missing metadata.`,
|
||||
);
|
||||
|
||||
console.groupEnd();
|
||||
|
||||
return validIcons;
|
||||
};
|
||||
|
||||
export const fetchDownloadUrls = async (
|
||||
nodeIds: string[],
|
||||
): Promise<Record<string, string>> => {
|
||||
/* Would just using Record be better here? Yes. But is a self-referencing return kinda cool? Also yes */
|
||||
const idUrlPairs: Awaited<ReturnType<typeof fetchDownloadUrls>> = {};
|
||||
|
||||
/* Figma accepts a comma-separated list of ids: GET /v1/images/:key?ids=1:2,1:3,1:4 */
|
||||
const paginatedNodeIds = splitIntoParts(nodeIds, 5);
|
||||
|
||||
for (const ids of paginatedNodeIds) {
|
||||
const data: GetImagesResponse | ErrorResponsePayloadWithErrMessage =
|
||||
await fetch(
|
||||
`https://api.figma.com/v1/images/${FIGMA_FILE_KEY}/?ids=${ids}&format=svg`,
|
||||
{
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
"X-FIGMA-TOKEN": process.env.FIGMA_TOKEN ?? "",
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.catch((e) => {
|
||||
throw e.message;
|
||||
});
|
||||
|
||||
if (data.err !== null) {
|
||||
throw new Error(data.err);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data.images)) {
|
||||
if (!value) {
|
||||
console.warn(`No image found for ${key}`);
|
||||
continue;
|
||||
}
|
||||
idUrlPairs[key] = value;
|
||||
}
|
||||
}
|
||||
return idUrlPairs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits an array of strings into parts where each part is a comma-separated string of all the strings for that part.
|
||||
* @returns An array of comma-separated strings.
|
||||
*/
|
||||
export function splitIntoParts(arr: string[], parts: number): string[] {
|
||||
const partSize = Math.ceil(arr.length / parts);
|
||||
const blocks: string[] = [];
|
||||
|
||||
for (let i = 0; i < parts && i * partSize < arr.length; i++) {
|
||||
const part = arr.slice(i * partSize, (i + 1) * partSize);
|
||||
blocks.push(part.join(","));
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import camelCase from "lodash.camelCase";
|
||||
import startCase from "lodash.startcase";
|
||||
|
||||
export const resolveName = (icon) => {
|
||||
const isVariant = icon.name.startsWith("Variant=");
|
||||
|
||||
if (isVariant) {
|
||||
const variant = icon.name.replace("Variant=", "").replace("Stroke", "");
|
||||
const actualName = icon["containing_frame"]?.["containingStateGroup"]?.name;
|
||||
const name = `${actualName}${variant}`;
|
||||
return `${startCase(camelCase(name)).replace(/ /g, "")}.svg`;
|
||||
}
|
||||
|
||||
return `${startCase(camelCase(icon.name)).replace(/ /g, "")}.svg`;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PublishedComponent } from "@figma/rest-api-spec";
|
||||
import { camelCase, startCase } from "lodash";
|
||||
|
||||
export const resolveName = (icon: PublishedComponent) => {
|
||||
const isVariant = icon.name.startsWith("Variant=");
|
||||
|
||||
if (isVariant) {
|
||||
const variant = icon.name.replace("Variant=", "").replace("Stroke", "");
|
||||
|
||||
/* @ts-expect-error Figma now includes the key:value pair `containingStateGroup: { name: string, nodeId: string }` */
|
||||
const actualName = icon.containing_frame?.containingStateGroup?.name;
|
||||
const name = `${actualName}${variant}`;
|
||||
return `${startCase(camelCase(name)).replace(/ /g, "")}.svg`;
|
||||
}
|
||||
|
||||
return `${startCase(camelCase(icon.name)).replace(/ /g, "")}.svg`;
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { fetchDownloadUrls, fetchIcon, fetchIcons } from "./fetch-icons.mjs";
|
||||
import { resolveName } from "./icon-name.mjs";
|
||||
import { makeConfig } from "./make-configs.mjs";
|
||||
import { paginate } from "./paginate.mjs";
|
||||
|
||||
/* https://www.figma.com/file/wEdyFjCQSBR3U7FvrMbPXa/Core-Icons-Next?node-id=277%3A1221&t=mUJzFvnsceYYXNL5-0 */
|
||||
|
||||
const iconFolder = "./icons";
|
||||
|
||||
if (!process.env.FIGMA_TOKEN) {
|
||||
throw new Error("FIGMA_TOKEN not set in .env");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.info("Started icon-update from Figma");
|
||||
let icons = await fetchIcons();
|
||||
|
||||
const totalIcons = icons.length;
|
||||
|
||||
const { images } = await fetchDownloadUrls(
|
||||
paginate(
|
||||
icons.map((x) => x.node_id),
|
||||
400,
|
||||
).map((x) => x.join(",")),
|
||||
);
|
||||
|
||||
if (existsSync(iconFolder)) {
|
||||
rmSync(iconFolder, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
mkdirSync(iconFolder);
|
||||
|
||||
console.info(`Laster ned ${Object.keys(images).length} ikoner`);
|
||||
|
||||
for (const [key, value] of Object.entries(images)) {
|
||||
const icon = await fetchIcon(value).catch((e) => {
|
||||
throw e;
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
icon &&
|
||||
writeFileSync(
|
||||
resolve(iconFolder, resolveName(icons.find((x) => x.node_id === key))),
|
||||
icon,
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
makeConfig(icons, iconFolder);
|
||||
|
||||
readdirSync(iconFolder, (_, files) => {
|
||||
if (files.length * 2 !== totalIcons) {
|
||||
throw new Error(`Ikoner mangler!`);
|
||||
}
|
||||
});
|
||||
|
||||
console.info(`Success! ${Object.keys(images).length} ikoner lastet ned`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,106 @@
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { fetchDownloadUrls, fetchIcons } from "./fetch-icons";
|
||||
import { resolveName } from "./icon-name";
|
||||
import { makeConfig } from "./make-configs";
|
||||
|
||||
/**
|
||||
* https://www.figma.com/file/wEdyFjCQSBR3U7FvrMbPXa/Core-Icons-Next?node-id=277%3A1221&t=mUJzFvnsceYYXNL5-0
|
||||
* This is the current source for all icons we are working with.
|
||||
*/
|
||||
|
||||
const iconFolder = "./icons";
|
||||
|
||||
if (!process.env.FIGMA_TOKEN) {
|
||||
throw new Error("FIGMA_TOKEN not set in .env");
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
async function main() {
|
||||
console.info("Started icon-update from Figma");
|
||||
/* Icons are published as 'components' in Figma */
|
||||
const publishedIconComponents = await fetchIcons();
|
||||
|
||||
/* When we have all the published icons, we can ask figma for URL's for downloading them as assets */
|
||||
const imagesUrls = await fetchDownloadUrls(
|
||||
publishedIconComponents.map((x) => x.node_id),
|
||||
);
|
||||
|
||||
/* Lets do a clean install */
|
||||
if (existsSync(iconFolder)) {
|
||||
rmSync(iconFolder, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(iconFolder);
|
||||
|
||||
console.group(`Processing ${Object.keys(imagesUrls).length} icons...`);
|
||||
|
||||
const fileNames = new Set<string>();
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(imagesUrls).map(async ([nodeId, iconUrl]) => {
|
||||
/* Each icon is now a raw string for a complete SVG */
|
||||
const iconSvg = await fetch(iconUrl)
|
||||
.then((x) => x.text())
|
||||
.catch((e) => {
|
||||
throw e.message;
|
||||
});
|
||||
|
||||
const matchingIcon = publishedIconComponents.find(
|
||||
(x) => x.node_id === nodeId,
|
||||
);
|
||||
|
||||
if (!matchingIcon) {
|
||||
throw new Error(
|
||||
`No matching icon found for ${nodeId}. It should not be possible to dowload icon without a matching icon in the list of icons fetched from Figma.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = resolveName(matchingIcon);
|
||||
|
||||
/*
|
||||
* In some cases if multiple icons are published in Figma with the same name,
|
||||
* we will end up overwriting the icon with the same name.
|
||||
*/
|
||||
if (fileNames.has(fileName)) {
|
||||
console.warn(`Duplicate name detected: ${fileName}.`);
|
||||
}
|
||||
|
||||
fileNames.add(fileName);
|
||||
|
||||
writeFileSync(resolve(iconFolder, resolveName(matchingIcon)), iconSvg, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (fileNames.size !== publishedIconComponents.length) {
|
||||
console.warn(
|
||||
`Duplicate icon names from Figma leads to them being overwritten. This will cause the icon-library to be out of sync with Figma.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.info(`Completed processing 🎉`);
|
||||
console.groupEnd();
|
||||
|
||||
makeConfig(publishedIconComponents, iconFolder);
|
||||
|
||||
const filesInDir = readdirSync(iconFolder);
|
||||
const svgFiles = filesInDir.filter((x) => x.endsWith(".svg"));
|
||||
const ymlFiles = filesInDir.filter((x) => x.endsWith(".yml"));
|
||||
|
||||
if (
|
||||
svgFiles.length !== publishedIconComponents.length ||
|
||||
ymlFiles.length !== publishedIconComponents.length
|
||||
) {
|
||||
throw new Error(
|
||||
`Icons (${svgFiles.length}) and configs (${ymlFiles.length}) written to directory does not match the expected amount of icons located in Figma (${publishedIconComponents.length}).\nThis is most likely caused by duplicate icon names from figma.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.info(
|
||||
`Success! A total of ${
|
||||
Object.keys(imagesUrls).length
|
||||
} were fetched and downloaded from Figma 🎉`,
|
||||
);
|
||||
}
|
||||
+35
-8
@@ -1,17 +1,40 @@
|
||||
import { PublishedComponent } from "@figma/rest-api-spec";
|
||||
import { writeFileSync } from "fs";
|
||||
import jsYaml from "js-yaml";
|
||||
import { dump } from "js-yaml";
|
||||
import { resolve } from "path";
|
||||
import { resolveName } from "./icon-name.mjs";
|
||||
import { resolveName } from "./icon-name";
|
||||
|
||||
export const makeConfig = (icons, folder) => {
|
||||
icons.forEach((icon) => {
|
||||
export type IconYml = {
|
||||
name: string;
|
||||
category: string;
|
||||
sub_category: string;
|
||||
keywords: string[];
|
||||
variant: string;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export const makeConfig = (
|
||||
icons: PublishedComponent[],
|
||||
dirLocation: string,
|
||||
) => {
|
||||
console.group("Creating icon yml...");
|
||||
|
||||
let counter = 0;
|
||||
for (const icon of icons) {
|
||||
const name = resolveName(icon).replace(".svg", "");
|
||||
const keywords = icon.description
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const config = {
|
||||
if (!icon.containing_frame?.pageName || !icon.containing_frame?.name) {
|
||||
throw new Error(
|
||||
`Missing containing_frame.pageName or containing_frame.name for icon ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const config: IconYml = {
|
||||
name,
|
||||
category: icon.containing_frame.pageName,
|
||||
sub_category: icon.containing_frame.name,
|
||||
@@ -33,14 +56,18 @@ export const makeConfig = (icons, folder) => {
|
||||
.join("."),
|
||||
};
|
||||
|
||||
const yml = jsYaml.dump(config, {
|
||||
const yml = dump(config, {
|
||||
noRefs: true,
|
||||
skipInvalid: false,
|
||||
quotingType: '"',
|
||||
});
|
||||
|
||||
writeFileSync(resolve(folder, `${config.name}.yml`), yml, {
|
||||
writeFileSync(resolve(dirLocation, `${config.name}.yml`), yml, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
});
|
||||
counter++;
|
||||
}
|
||||
|
||||
console.info(`Created ${counter} icon yml files`);
|
||||
console.groupEnd();
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
export function paginate(arr, size) {
|
||||
return arr.reduce((acc, val, i) => {
|
||||
let idx = Math.floor(i / size);
|
||||
if (!acc[idx]) {
|
||||
acc[idx] = [];
|
||||
}
|
||||
let page = acc[idx];
|
||||
page.push(val);
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ ymlList.forEach((file) => {
|
||||
const ymlData = jsYaml.load(fs.readFileSync(`${basePath}/${file}`), {
|
||||
schema: jsYaml.JSON_SCHEMA,
|
||||
});
|
||||
// biome-ignore lint/complexity/useOptionalChain: optional chain not supported in enviroment. Consider revriting to Typescript
|
||||
if (ymlData.keywords && ymlData.keywords.includes("[ignore-docs]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-url": "^10.1.3",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "5.5.4",
|
||||
"vite": "^5.4.6",
|
||||
"vite-plugin-singlefile": "^1.0.0",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "7.2.0",
|
||||
"version": "7.3.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
@@ -62,28 +62,30 @@
|
||||
"update-metadata": "node config/metadata.js",
|
||||
"write-packagejson": "echo '{\"type\": \"module\"}' > dist/react/esm/package.json",
|
||||
"build": "yarn copy && yarn create-icons && concurrently \"tsc\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && node config/cleanTypes.js && yarn update-metadata && yarn parse-svg",
|
||||
"fetch-new:icons": "tsx --env-file=.env config/figma/index.mjs",
|
||||
"fetch-new:icons": "tsx --env-file=.env config/figma/index.ts",
|
||||
"zip:icons": "node config/zip.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@figma/rest-api-spec": "^0.19.0",
|
||||
"@svgr/cli": "8.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^18.3.11",
|
||||
"adm-zip": "^0.5.10",
|
||||
"concurrently": "7.2.1",
|
||||
"concurrently": "9.0.1",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cross-zip-cli": "1.0.0",
|
||||
"fast-glob": "3.2.11",
|
||||
"hast-util-select": "5.0.5",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"lodash.startcase": "4.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
"prettier": "3.1.1",
|
||||
"react": "^18.0.0",
|
||||
"rehype-parse": "8.0.4",
|
||||
"tsc-alias": "1.8.8",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "5.5.4",
|
||||
"unified": "10.1.2",
|
||||
"vitest": "^1.2.2"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 7.3.0
|
||||
|
||||
## 7.2.1
|
||||
|
||||
## 7.2.0
|
||||
|
||||
## 7.1.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "7.2.0",
|
||||
"version": "7.3.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/stylelint",
|
||||
"repository": {
|
||||
@@ -35,14 +35,14 @@
|
||||
"dev": "yarn watch:lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-css": "^7.2.0",
|
||||
"@navikt/ds-tokens": "^7.2.0",
|
||||
"concurrently": "7.2.1",
|
||||
"@navikt/ds-css": "^7.3.0",
|
||||
"@navikt/ds-tokens": "^7.3.0",
|
||||
"concurrently": "9.0.1",
|
||||
"postcss-selector-parser": "^6.0.13",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"stylelint": "^14.8.5",
|
||||
"stylelint-test-rule-node": "^0.2.1",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 7.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`20ced9b`](https://github.com/navikt/aksel/commit/20ced9b084a1f4bb56a1e1c07ef92010c9f4d71c), [`536f088`](https://github.com/navikt/aksel/commit/536f088b97ab9ae139d6974deb2a3c69e4fb6db3)]:
|
||||
- @navikt/ds-css@7.3.0
|
||||
|
||||
## 7.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`6ddea4eaf`](https://github.com/navikt/aksel/commit/6ddea4eaf2e37f20f93d75e1a3a327b038fb209b)]:
|
||||
- @navikt/ds-css@7.2.1
|
||||
|
||||
## 7.2.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "7.2.0",
|
||||
"version": "7.3.0",
|
||||
"description": "Aksel command line interface. Handles css-imports, codemods and more",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"license": "MIT",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/kommandolinje",
|
||||
"dependencies": {
|
||||
"@navikt/ds-css": "7.2.0",
|
||||
"@navikt/ds-css": "7.3.0",
|
||||
"axios": "1.7.4",
|
||||
"chalk": "4.1.0",
|
||||
"clipboardy": "^2.3.0",
|
||||
@@ -45,7 +45,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "6.0.1",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "^1.2.2"
|
||||
},
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -43,7 +43,7 @@ export function validateGit(options: any, program: Command) {
|
||||
try {
|
||||
clean = isGitClean.sync(process.cwd());
|
||||
} catch (err: any) {
|
||||
if (err && err.stderr && err.stderr.indexOf("Not a git repository") >= 0) {
|
||||
if (err?.stderr && err.stderr.indexOf("Not a git repository") >= 0) {
|
||||
clean = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export async function getAllVersions() {
|
||||
const npmPackageData = await axios.get(
|
||||
`https://registry.npmjs.org/@navikt/ds-css`,
|
||||
);
|
||||
return Object.keys(npmPackageData.data["versions"]);
|
||||
return Object.keys(npmPackageData.data.versions);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return [];
|
||||
|
||||
@@ -137,7 +137,7 @@ export async function cssImportsCommand() {
|
||||
|
||||
let foundComponents: string[] = [];
|
||||
|
||||
if (answers["autoscan"] === "yes") {
|
||||
if (answers.autoscan === "yes") {
|
||||
foundComponents = await new Promise((resolve) => {
|
||||
exec(`node ${__dirname}/scan-code.js ${answers.scandir}`, (_, stdout) => {
|
||||
resolve(
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 7.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ActionMenu: :tada: New component! Replaces DropdownMenu as the go-to solution for floating menus. Includes support for grouping, sub-menus, checkboxes, radios and dividers. Read more in the [documentation](https://aksel.nav.no/komponenter/core/actionmenu). ([#3081](https://github.com/navikt/aksel/pull/3081))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- List.Item: Center icon horizontally inside it's container ([#3253](https://github.com/navikt/aksel/pull/3253))
|
||||
|
||||
## 7.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Combobox: Support PageUp/PageDown in dropdown list. ([#3158](https://github.com/navikt/aksel/pull/3158))
|
||||
|
||||
## 7.2.0
|
||||
|
||||
## 7.1.2
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
.navds-action-menu__content {
|
||||
overflow: hidden;
|
||||
box-shadow: var(--a-shadow-medium);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
}
|
||||
|
||||
/* stylelint-disable csstools/value-no-unknown-custom-properties */
|
||||
.navds-action-menu__content > .navds-action-menu__content-inner {
|
||||
--__ac-action-menu-content-p: var(--a-spacing-2);
|
||||
--__ac-action-menu-item-pr: var(--a-spacing-3);
|
||||
--__ac-action-menu-item-pl: var(--a-spacing-2);
|
||||
--__ac-action-menu-item-height: 2rem;
|
||||
|
||||
border-radius: var(--a-border-radius-large);
|
||||
background-color: var(--a-surface-default);
|
||||
min-width: 128px;
|
||||
max-width: min(95vw, 640px);
|
||||
transform-origin: var(--__ac-action-menu-content-transform-origin);
|
||||
animation-duration: 160ms;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
padding: var(--__ac-action-menu-content-p);
|
||||
overflow: auto;
|
||||
max-height: var(--__ac-action-menu-content-available-height);
|
||||
}
|
||||
|
||||
.navds-action-menu__content:where([data-state="open"]):where([data-side="bottom"]) {
|
||||
animation-name: aksel-action-from-bottom, aksel-action-fade-in;
|
||||
}
|
||||
|
||||
.navds-action-menu__content:where([data-state="open"]):where([data-side="top"]) {
|
||||
animation-name: aksel-action-from-top, aksel-action-fade-in;
|
||||
}
|
||||
|
||||
.navds-action-menu__content:where([data-state="open"]):where([data-side="left"]) {
|
||||
animation-name: aksel-action-from-left, aksel-action-fade-in;
|
||||
}
|
||||
|
||||
.navds-action-menu__content:where([data-state="open"]):where([data-side="right"]) {
|
||||
animation-name: aksel-action-from-right, aksel-action-fade-in;
|
||||
}
|
||||
|
||||
@keyframes aksel-action-from-bottom {
|
||||
from {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aksel-action-from-top {
|
||||
from {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aksel-action-from-left {
|
||||
from {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aksel-action-from-right {
|
||||
from {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aksel-action-fade-in {
|
||||
from {
|
||||
opacity: 0.01;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.navds-action-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--a-spacing-2);
|
||||
min-height: var(--__ac-action-menu-item-height);
|
||||
cursor: default;
|
||||
border-radius: var(--a-border-radius-medium);
|
||||
position: relative;
|
||||
padding-left: var(--__ac-action-menu-item-pl);
|
||||
padding-right: var(--__ac-action-menu-item-pr);
|
||||
font-size: var(--a-font-size-medium);
|
||||
scroll-margin-block: var(--__ac-action-menu-content-p);
|
||||
line-height: 1.5;
|
||||
color: var(--a-text-default);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navds-action-menu__item svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navds-action-menu__item--has-icon {
|
||||
--__ac-action-menu-item-pl: var(--a-spacing-6);
|
||||
}
|
||||
|
||||
.navds-action-menu__sub-trigger {
|
||||
--__ac-action-menu-item-pr: var(--a-spacing-05);
|
||||
}
|
||||
|
||||
.navds-action-menu__item:focus {
|
||||
outline: none;
|
||||
background-color: var(--a-surface-action-subtle-hover);
|
||||
color: var(--a-text-default);
|
||||
}
|
||||
|
||||
.navds-action-menu__item--danger {
|
||||
color: var(--a-text-danger);
|
||||
}
|
||||
|
||||
.navds-action-menu__item--danger:focus {
|
||||
background-color: var(--a-surface-danger-subtle);
|
||||
}
|
||||
|
||||
.navds-action-menu__marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--a-spacing-1);
|
||||
}
|
||||
|
||||
.navds-action-menu__marker--right {
|
||||
margin-left: auto;
|
||||
padding-left: var(--a-spacing-4);
|
||||
}
|
||||
|
||||
.navds-action-menu__marker--left {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: var(--__ac-action-menu-item-pl);
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.navds-action-menu__marker-icon svg {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navds-action-menu__shortcut {
|
||||
background-color: var(--a-surface-neutral-subtle);
|
||||
color: var(--a-text-default);
|
||||
border-radius: var(--a-border-radius-small);
|
||||
padding: 0 var(--a-spacing-1);
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--a-font-size-small);
|
||||
}
|
||||
|
||||
.navds-action-menu__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: calc(var(--__ac-action-menu-item-height) - 6px);
|
||||
padding-right: var(--__ac-action-menu-item-pr);
|
||||
padding-left: var(--__ac-action-menu-item-pl);
|
||||
color: var(--a-text-subtle);
|
||||
border-radius: var(--a-border-radius-medium);
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
font-size: var(--a-font-size-small);
|
||||
}
|
||||
|
||||
.navds-action-menu__divider {
|
||||
height: 1px;
|
||||
margin-block: var(--a-spacing-2);
|
||||
background-color: var(--a-border-divider);
|
||||
}
|
||||
|
||||
.navds-action-menu__indicator {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
.navds-action-menu__indicator-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.navds-action-menu__indicator-icon--unchecked,
|
||||
.navds-action-menu__indicator-icon--checked,
|
||||
.navds-action-menu__indicator-icon--indeterminate {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navds-action-menu__indicator[data-state="unchecked"] .navds-action-menu__indicator-icon--unchecked {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navds-action-menu__indicator:where([data-state="checked"]) .navds-action-menu__indicator-icon--checked {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navds-action-menu__indicator:where([data-state="indeterminate"]) .navds-action-menu__indicator-icon--indeterminate {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navds-action-menu__item:where([aria-disabled="true"]) {
|
||||
color: var(--a-text-subtle);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.navds-action-menu__sub-trigger:where([data-state="open"]) {
|
||||
background-color: var(--a-surface-neutral-subtle);
|
||||
}
|
||||
@@ -47,6 +47,8 @@ legend {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Make elements with the HTML hidden attribute stay hidden by default. */
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
@@ -69,6 +69,11 @@ const StyleMappings = {
|
||||
dependencies: [typoCss],
|
||||
},
|
||||
{ component: "Alert", main: "alert.css", dependencies: [typoCss] },
|
||||
{
|
||||
component: "ActionMenu",
|
||||
main: "action-menu.css",
|
||||
dependencies: [typoCss],
|
||||
},
|
||||
{
|
||||
component: "BodyLong",
|
||||
main: typoCss,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Template for writing new CSS
|
||||
|
||||
## File setup
|
||||
|
||||
1. Create new CSS-file for component: "alert.darkside.css", "button.darkside.css" etc. Remember to add `.darkside.` for easier lookup.
|
||||
2. Import file in "darkside/index.css": `@import "./alert.darkside.css`
|
||||
|
||||
Files will not be included in `/dist` for now to avoid it potentially polluting production-code. This will need to be revisited when we want to expose the new systems outside the team.
|
||||
|
||||
## Development
|
||||
|
||||
1. Write new CSS for component. Use same prefix `.navds` as before.
|
||||
2. Run storybook and change from "Default (old)" to "Darkside (new)" in toolbar. You are now developing new CSS 🎉
|
||||
|
||||
## Syntax
|
||||
|
||||
- Test using outline + outline-offset for focus-markings.
|
||||
|
||||
## Checklist
|
||||
|
||||
- Add new CSS-files under `core/css/darkside`.
|
||||
- New files should include `.darkside.` for easier lookup.
|
||||
- Import new CSS-files in `core/css/darkside/index.css`
|
||||
- New CSS-selectors should have `.navds` as prefix, same as original code.
|
||||
- Test changes in Storybook by changing mode in toolbar from "default (old)" -> "darkside (new)"
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: italic;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-italic-cyrillic_ext.woff2) format("woff2");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: italic;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-italic-cyrillic.woff2) format("woff2");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: italic;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-italic-latin_ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: italic;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-italic.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-normal-cyrillic_ext.woff2) format("woff2");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-normal-cyrillic.woff2) format("woff2");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-normal-latin_ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-normal.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Legacy support */
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-style: italic;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-italic-latin_ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-style: italic;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-italic.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-normal-latin_ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3-normal.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/* ==========================================================================
|
||||
Print styles copied from https://github.com/h5bp/html5-boilerplate/blob/master/dist/css/style.css
|
||||
Inlined to avoid the additional HTTP request:
|
||||
https://www.phpied.com/delay-loading-your-print-css/
|
||||
========================================================================== */
|
||||
|
||||
@media print {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
|
||||
/* Black prints faster */
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
a,
|
||||
a:visited {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a[href]::after {
|
||||
content: " (" attr(href) ")";
|
||||
}
|
||||
|
||||
abbr[title]::after {
|
||||
content: " (" attr(title) ")";
|
||||
}
|
||||
|
||||
/*
|
||||
* Don't show links that are fragment identifiers,
|
||||
* or use the `javascript:` pseudo protocol
|
||||
*/
|
||||
a[href^="#"]::after,
|
||||
a[href^="javascript:"]::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
|
||||
pre,
|
||||
blockquote {
|
||||
border: 1px solid #999;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/*
|
||||
* Printing Tables:
|
||||
* https://web.archive.org/web/20180815150934/http://css-discuss.incutio.com/wiki/Printing_Tables
|
||||
*/
|
||||
thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
tr,
|
||||
img {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
p,
|
||||
h2,
|
||||
h3 {
|
||||
orphans: 3;
|
||||
widows: 3;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/* Modern-normalize: https://github.com/sindresorhus/modern-normalize/blob/main/modern-normalize.css */
|
||||
|
||||
/* Better box-model */
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
line-height: 1.15; /* Correct the line height in all browsers. */
|
||||
-webkit-text-size-adjust: 100%; /* Prevent adjustments of font size after orientation changes in iOS. */
|
||||
tab-size: 4; /* Use a more readable tab size (opinionated). */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0; /* Remove the margin in all browsers. */
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||
2. Correct the odd 'em' font sizing in all browsers.
|
||||
*/
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Add the correct font size in all browsers.
|
||||
*/
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
|
||||
*/
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/**
|
||||
Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
*/
|
||||
table {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Change the font styles in all browsers.
|
||||
2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
line-height: 1.15; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
button,
|
||||
[type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
|
||||
*/
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Correct the odd appearance in Chrome and Safari.
|
||||
2. Correct the outline style in Safari.
|
||||
*/
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Change font properties to 'inherit' in Safari.
|
||||
*/
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* To support nested theming, we want to declare color on each scoped elements */
|
||||
:root,
|
||||
:host,
|
||||
.light {
|
||||
color: var(--a-text-default);
|
||||
}
|
||||
|
||||
.dark {
|
||||
color: var(--a-text-default);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* https://web.dev/prefers-reduced-motion/ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*:not(.navds-loader *):not(.navds-loader):not(.navds-progress-bar *):not(.navds-progress-bar),
|
||||
::before,
|
||||
::after {
|
||||
animation-delay: -1ms !important;
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
background-attachment: initial !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* From https://github.com/h5bp/html5-boilerplate/blob/master/dist/css/style.css
|
||||
*
|
||||
* Hide only visually, but have it available for screen readers:
|
||||
* https://snook.ca/archives/html_and_css/hiding-content-for-accessibility
|
||||
*
|
||||
* 1. For long content, line feeds are not interpreted as spaces and small width
|
||||
* causes content to wrap 1 word per line:
|
||||
* https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe
|
||||
*/
|
||||
|
||||
.sr-only,
|
||||
.navds-sr-only {
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
/*
|
||||
* From https://github.com/h5bp/html5-boilerplate/blob/master/dist/css/style.css
|
||||
*
|
||||
* Extends the .sr-only class to allow the element
|
||||
* to be focusable when navigated to via the keyboard:
|
||||
* https://www.drupal.org/node/897638
|
||||
*/
|
||||
|
||||
.sr-only.focusable:active,
|
||||
.sr-only.focusable:focus,
|
||||
.navds-sr-only.focusable:active,
|
||||
.navds-sr-only.focusable:focus {
|
||||
clip: auto;
|
||||
clip-path: none;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
position: static;
|
||||
white-space: inherit;
|
||||
width: auto;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* stylelint-disable csstools/value-no-unknown-custom-properties */
|
||||
/* stylelint-disable aksel/design-token-exists */
|
||||
|
||||
.navds-button {
|
||||
background-color: var(--a-bg-accent-strong);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
@layer aksel.reset, aksel.typography, aksel.theme, aksel.components, aksel.utilities;
|
||||
|
||||
/* CSS foundations */
|
||||
@import url("./baseline/reset.darkside.css") layer(aksel.reset);
|
||||
@import url("./baseline/print.darkside.css") layer(aksel.reset);
|
||||
@import url("./baseline/fonts.darkside.css") layer(aksel.typography);
|
||||
@import url("./baseline/theme.darkside.css") layer(aksel.theme);
|
||||
@import url("./typography.darkside.css") layer(aksel.typography);
|
||||
@import url("./baseline/utilities.darkside.css") layer(aksel.utilities);
|
||||
|
||||
/* Theming */
|
||||
@import url("@navikt/ds-tokens/dist/darkside/tokens.css") layer(aksel.theme);
|
||||
|
||||
/* Components */
|
||||
@import url("./button.darkside.css") layer(aksel.components);
|
||||
@@ -0,0 +1,6 @@
|
||||
body,
|
||||
:host {
|
||||
font-family: "Source Sans 3", "Source Sans Pro", Arial, sans-serif;
|
||||
line-height: 1.333;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
@@ -263,6 +263,7 @@
|
||||
border-radius: var(--a-border-radius-medium);
|
||||
background-color: var(--ac-combobox-list-bg, var(--a-surface-default));
|
||||
color: var(--ac-combobox-list-text, var(--a-text-default));
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.navds-combobox__list--closed {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@import "chips.css";
|
||||
@import "copybutton.css";
|
||||
@import "dropdown.css";
|
||||
@import "action-menu.css";
|
||||
@import "expansioncard.css";
|
||||
@import "guide-panel.css";
|
||||
@import "form/index.css";
|
||||
|
||||
@@ -80,5 +80,6 @@ _:future,
|
||||
|
||||
.navds-list__item-marker--icon {
|
||||
font-size: 1.5rem;
|
||||
justify-content: center;
|
||||
color: var(--ac-list-marker-icon-color, var(--ac-list-marker-color, var(--a-icon-default)));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "7.2.0",
|
||||
"version": "7.3.0",
|
||||
"description": "CSS for NAV Designsystem",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
@@ -27,10 +27,11 @@
|
||||
"css:get-version": "node config/get-version.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^7.2.0",
|
||||
"@navikt/ds-tokens": "^7.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cssnano": "6.0.0",
|
||||
"fast-glob": "3.2.11",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "^4.17.21",
|
||||
"nodemon": "^3.0.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 7.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ActionMenu: :tada: New component! Replaces DropdownMenu as the go-to solution for floating menus. Includes support for grouping, sub-menus, checkboxes, radios and dividers. Read more in the [documentation](https://aksel.nav.no/komponenter/core/actionmenu). ([#3081](https://github.com/navikt/aksel/pull/3081))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Accordion: Remove redundant icon title ([#3230](https://github.com/navikt/aksel/pull/3230))
|
||||
|
||||
- Modal: Close button now avoids triggering when repeated keystrokes are detected. This helps stop accidental closing of Modal when 'Enter'-key is held down. ([#3081](https://github.com/navikt/aksel/pull/3081))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@7.3.0
|
||||
- @navikt/aksel-icons@7.3.0
|
||||
|
||||
## 7.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Combobox: Disable autocomple in Firefox on Android to prevent bug ([#3201](https://github.com/navikt/aksel/pull/3201))
|
||||
|
||||
- Combobox: Support PageUp/PageDown in dropdown list. ([#3158](https://github.com/navikt/aksel/pull/3158))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@7.2.1
|
||||
- @navikt/aksel-icons@7.2.1
|
||||
|
||||
## 7.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "7.2.0",
|
||||
"version": "7.3.0",
|
||||
"description": "React components from the Norwegian Labour and Welfare Administration.",
|
||||
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
|
||||
"license": "MIT",
|
||||
@@ -50,6 +50,16 @@
|
||||
"default": "./cjs/accordion/index.js"
|
||||
}
|
||||
},
|
||||
"./ActionMenu": {
|
||||
"import": {
|
||||
"types": "./esm/overlays/action-menu/index.d.ts",
|
||||
"default": "./esm/overlays/action-menu/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/overlays/action-menu/index.d.ts",
|
||||
"default": "./cjs/overlays/action-menu/index.js"
|
||||
}
|
||||
},
|
||||
"./Alert": {
|
||||
"import": {
|
||||
"types": "./esm/alert/index.d.ts",
|
||||
@@ -594,8 +604,8 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.25.4",
|
||||
"@floating-ui/react-dom": "^2.0.9",
|
||||
"@navikt/aksel-icons": "^7.2.0",
|
||||
"@navikt/ds-tokens": "^7.2.0",
|
||||
"@navikt/aksel-icons": "^7.3.0",
|
||||
"@navikt/ds-tokens": "^7.3.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.0.0",
|
||||
"react-day-picker": "8.10.0"
|
||||
@@ -605,9 +615,7 @@
|
||||
"@testing-library/jest-dom": "^5.16.0",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@testing-library/user-event": "^14.2.0",
|
||||
"@types/faker": "5.5.8",
|
||||
"concurrently": "7.2.1",
|
||||
"faker": "5.5.3",
|
||||
"concurrently": "9.0.1",
|
||||
"fast-glob": "3.2.11",
|
||||
"jsdom": "24.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
@@ -616,7 +624,7 @@
|
||||
"swr": "^1.1.2",
|
||||
"tsc-alias": "1.8.8",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "^1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -38,7 +38,6 @@ const AccordionHeader = forwardRef<HTMLButtonElement, AccordionHeaderProps>(
|
||||
<span className="navds-accordion__icon-wrapper">
|
||||
<ChevronDownIcon
|
||||
className="navds-accordion__header-chevron"
|
||||
title="Vis mer"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@navikt/aksel-icons";
|
||||
import { Button } from "../button";
|
||||
import { BodyLong } from "../typography";
|
||||
import { useI18n } from "../util/i18n/i18n.context";
|
||||
|
||||
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
@@ -54,25 +55,13 @@ export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const Icon = ({ variant, ...props }) => {
|
||||
switch (variant) {
|
||||
case "error":
|
||||
return <XMarkOctagonFillIcon title="Feil" {...props} />;
|
||||
case "warning":
|
||||
return <ExclamationmarkTriangleFillIcon title="Advarsel" {...props} />;
|
||||
case "info":
|
||||
return <InformationSquareFillIcon title="Informasjon" {...props} />;
|
||||
case "success":
|
||||
return <CheckmarkCircleFillIcon title="Suksess" {...props} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
const IconMap = {
|
||||
error: XMarkOctagonFillIcon,
|
||||
warning: ExclamationmarkTriangleFillIcon,
|
||||
info: InformationSquareFillIcon,
|
||||
success: CheckmarkCircleFillIcon,
|
||||
};
|
||||
|
||||
export interface AlertContextProps {
|
||||
size: "medium" | "small";
|
||||
}
|
||||
|
||||
/**
|
||||
* A component for displaying alerts
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/alert)
|
||||
@@ -98,6 +87,8 @@ export const Alert = forwardRef<HTMLDivElement, AlertProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const translate = useI18n("Alert");
|
||||
const Icon = IconMap[variant];
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
@@ -114,7 +105,7 @@ export const Alert = forwardRef<HTMLDivElement, AlertProps>(
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Icon variant={variant} className="navds-alert__icon" />
|
||||
<Icon title={translate(variant)} className="navds-alert__icon" />
|
||||
<BodyLong
|
||||
as="div"
|
||||
size={size}
|
||||
@@ -137,8 +128,8 @@ export const Alert = forwardRef<HTMLDivElement, AlertProps>(
|
||||
<XMarkIcon
|
||||
title={
|
||||
["error", "warning"].includes(variant)
|
||||
? "Lukk varsel"
|
||||
: "Lukk melding"
|
||||
? translate("closeAlert")
|
||||
: translate("closeMessage")
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -190,3 +190,19 @@ export const ChromaticForcedColors: Story = {
|
||||
chromatic: { disable: false, forcedColors: "active" },
|
||||
},
|
||||
};
|
||||
|
||||
export const ChromaticDarksideDark: Story = {
|
||||
render: Chromatic.render,
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
globals: { theme: "dark", mode: "darkside" },
|
||||
};
|
||||
|
||||
export const ChromaticDarksideLight: Story = {
|
||||
render: Chromatic.render,
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
globals: { theme: "light", mode: "darkside" },
|
||||
};
|
||||
@@ -58,7 +58,7 @@ export const Size: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
{SIZES.map((size) => (
|
||||
<>
|
||||
<React.Fragment key={size}>
|
||||
<h3>{size}</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
@@ -75,7 +75,7 @@ export const Size: Story = {
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</VStack>
|
||||
),
|
||||
@@ -85,7 +85,7 @@ export const Variants: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
{VARIANTS.map((variant) => (
|
||||
<>
|
||||
<React.Fragment key={variant}>
|
||||
<h3>{variant}</h3>
|
||||
<Chat
|
||||
avatar="NAV"
|
||||
@@ -97,7 +97,7 @@ export const Variants: Story = {
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</VStack>
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { XMarkIcon } from "@navikt/aksel-icons";
|
||||
import { composeEventHandlers } from "../util/composeEventHandlers";
|
||||
import { useI18n } from "../util/i18n/i18n.context";
|
||||
|
||||
export interface ChipsRemovableProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
@@ -11,15 +12,15 @@ export interface ChipsRemovableProps
|
||||
* @default "action"
|
||||
*/
|
||||
variant?: "action" | "neutral";
|
||||
/**
|
||||
* Replaces label read for screen-readers
|
||||
* @default "slett filter"
|
||||
*/
|
||||
removeLabel?: string;
|
||||
/**
|
||||
* Click callback
|
||||
*/
|
||||
onDelete?: () => void;
|
||||
/**
|
||||
* Replaces label read for screen-readers
|
||||
* @default "slett"
|
||||
*/
|
||||
removeLabel?: string;
|
||||
}
|
||||
|
||||
export const RemovableChips = forwardRef<
|
||||
@@ -28,17 +29,18 @@ export const RemovableChips = forwardRef<
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
variant = "action",
|
||||
removeLabel = "slett",
|
||||
onDelete,
|
||||
type = "button",
|
||||
removeLabel,
|
||||
className,
|
||||
onClick,
|
||||
type = "button",
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const translate = useI18n("Chips");
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
@@ -49,7 +51,9 @@ export const RemovableChips = forwardRef<
|
||||
className,
|
||||
`navds-chips__removable--${variant}`,
|
||||
)}
|
||||
aria-label={`${children} ${removeLabel}`}
|
||||
aria-label={`${children} ${
|
||||
removeLabel ?? translate("Removable.labelSuffix")
|
||||
}`}
|
||||
onClick={composeEventHandlers(onClick, onDelete)}
|
||||
>
|
||||
<span className="navds-chips__chip-text">{children}</span>
|
||||
|
||||
@@ -27,8 +27,8 @@ interface DatePickerComponent
|
||||
* ```jsx
|
||||
* <DatePicker.Standalone
|
||||
* dropdownCaption
|
||||
* fromDate={new Date("2020-10-01")}
|
||||
* toDate={new Date("2024-10-01")}
|
||||
* fromDate={new Date("2022-10-01")}
|
||||
* toDate={new Date("2026-10-01")}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -19,8 +19,8 @@ interface MonthPickerComponent
|
||||
* ```jsx
|
||||
* <MonthPicker.Standalone
|
||||
* dropdownCaption
|
||||
* fromDate={new Date("1 Oct 2020")}
|
||||
* toDate={new Date("1 Oct 2024")}
|
||||
* fromDate={new Date("1 Oct 2022")}
|
||||
* toDate={new Date("1 Oct 2026")}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -82,7 +82,7 @@ export const Menu = forwardRef<HTMLDivElement, DropdownMenuProps>(
|
||||
open={isOpen}
|
||||
onClose={() => {
|
||||
handleToggle(false);
|
||||
onClose && onClose();
|
||||
onClose?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import faker from "faker";
|
||||
import React from "react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { Checkbox, CheckboxGroup } from ".";
|
||||
@@ -10,8 +9,8 @@ const firstArgumentOfFirstCall = (fn: ReturnType<typeof vi.fn>) =>
|
||||
test("checkbox group chains onChange calls", async () => {
|
||||
const onGroupChange = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
const value = faker.datatype.string();
|
||||
const label = faker.datatype.string();
|
||||
const value = "Checkbox value";
|
||||
const label = "My pretty label";
|
||||
|
||||
render(
|
||||
<CheckboxGroup legend="legend" onChange={onGroupChange}>
|
||||
|
||||
@@ -44,8 +44,8 @@ const useCheckbox = (props: CheckboxProps) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
props.onChange && props.onChange(e);
|
||||
checkboxGroup && checkboxGroup.toggleValue(props.value);
|
||||
props.onChange?.(e);
|
||||
checkboxGroup?.toggleValue(props.value);
|
||||
},
|
||||
onClick: (e) => {
|
||||
if (readOnly) {
|
||||
|
||||
@@ -51,13 +51,21 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
shouldAutocomplete,
|
||||
shouldAutocomplete: externalShouldAutocomplete,
|
||||
size,
|
||||
...rest
|
||||
} = props;
|
||||
const options = mapToComboboxOptionArray(externalOptions) || [];
|
||||
const filteredOptions = mapToComboboxOptionArray(externalFilteredOptions);
|
||||
const selectedOptions = mapToComboboxOptionArray(externalSelectedOptions);
|
||||
|
||||
const userAgent =
|
||||
typeof navigator === "undefined" ? "" : navigator.userAgent;
|
||||
const isFirefoxOnAndroid =
|
||||
userAgent.includes("Android") && userAgent.includes("Firefox/");
|
||||
const shouldAutocomplete =
|
||||
!isFirefoxOnAndroid && externalShouldAutocomplete;
|
||||
|
||||
return (
|
||||
<InputContextProvider
|
||||
value={{
|
||||
|
||||
@@ -140,7 +140,7 @@ const FilteredOptionsProvider = ({
|
||||
if (disabled || readOnly) {
|
||||
return;
|
||||
}
|
||||
virtualFocus.moveFocusToTop();
|
||||
virtualFocus.resetFocus();
|
||||
if (newState ?? !isInternalListOpen) {
|
||||
setHideCaret(!!maxSelected?.isLimitReached);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type VirtualFocusType = {
|
||||
activeElement: HTMLElement | undefined;
|
||||
@@ -10,6 +10,9 @@ export type VirtualFocusType = {
|
||||
moveFocusToElement: (id: string) => void;
|
||||
moveFocusToTop: () => void;
|
||||
moveFocusToBottom: () => void;
|
||||
moveFocusUpBy: (numberOfElements: number) => void;
|
||||
moveFocusDownBy: (numberOfElements: number) => void;
|
||||
resetFocus: () => void;
|
||||
};
|
||||
|
||||
const useVirtualFocus = (
|
||||
@@ -40,11 +43,6 @@ const useVirtualFocus = (
|
||||
: false;
|
||||
};
|
||||
|
||||
const _moveFocusAndScrollTo = (_element?: HTMLElement) => {
|
||||
setActiveElement(_element);
|
||||
_element?.scrollIntoView?.({ block: "nearest" });
|
||||
};
|
||||
|
||||
const moveFocusUp = () => {
|
||||
if (!activeElement) {
|
||||
return;
|
||||
@@ -55,14 +53,14 @@ const useVirtualFocus = (
|
||||
if (_currentIndex === 0) {
|
||||
setActiveElement(undefined);
|
||||
} else {
|
||||
_moveFocusAndScrollTo(elementAbove);
|
||||
setActiveElement(elementAbove);
|
||||
}
|
||||
};
|
||||
|
||||
const moveFocusDown = () => {
|
||||
const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus();
|
||||
if (!activeElement) {
|
||||
_moveFocusAndScrollTo(elementsAbleToReceiveFocus[0]);
|
||||
setActiveElement(elementsAbleToReceiveFocus[0]);
|
||||
return;
|
||||
}
|
||||
const _currentIndex = elementsAbleToReceiveFocus.indexOf(activeElement);
|
||||
@@ -70,13 +68,17 @@ const useVirtualFocus = (
|
||||
return;
|
||||
}
|
||||
|
||||
_moveFocusAndScrollTo(elementsAbleToReceiveFocus[_currentIndex + 1]);
|
||||
setActiveElement(elementsAbleToReceiveFocus[_currentIndex + 1]);
|
||||
};
|
||||
|
||||
const moveFocusToTop = () => _moveFocusAndScrollTo(undefined);
|
||||
const resetFocus = () => setActiveElement(undefined);
|
||||
const moveFocusToTop = () => {
|
||||
const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus();
|
||||
setActiveElement(elementsAbleToReceiveFocus[0]);
|
||||
};
|
||||
const moveFocusToBottom = () => {
|
||||
const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus();
|
||||
return _moveFocusAndScrollTo(
|
||||
setActiveElement(
|
||||
elementsAbleToReceiveFocus[elementsAbleToReceiveFocus.length - 1],
|
||||
);
|
||||
};
|
||||
@@ -89,6 +91,32 @@ const useVirtualFocus = (
|
||||
}
|
||||
};
|
||||
|
||||
const moveFocusUpBy = (numberOfElements: number) => {
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus();
|
||||
const currentIndex = elementsAbleToReceiveFocus.indexOf(activeElement);
|
||||
const newIndex = Math.max(currentIndex - numberOfElements, 0);
|
||||
setActiveElement(elementsAbleToReceiveFocus[newIndex]);
|
||||
};
|
||||
|
||||
const moveFocusDownBy = (numberOfElements: number) => {
|
||||
const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus();
|
||||
const currentIndex = activeElement
|
||||
? elementsAbleToReceiveFocus.indexOf(activeElement)
|
||||
: -1;
|
||||
const newIndex = Math.min(
|
||||
currentIndex + numberOfElements,
|
||||
elementsAbleToReceiveFocus.length - 1,
|
||||
);
|
||||
setActiveElement(elementsAbleToReceiveFocus[newIndex]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
activeElement?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [activeElement]);
|
||||
|
||||
return {
|
||||
activeElement,
|
||||
getElementById,
|
||||
@@ -99,6 +127,9 @@ const useVirtualFocus = (
|
||||
moveFocusToElement,
|
||||
moveFocusToTop,
|
||||
moveFocusToBottom,
|
||||
moveFocusUpBy,
|
||||
moveFocusDownBy,
|
||||
resetFocus,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -134,14 +134,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
case "Accept":
|
||||
onEnter(e);
|
||||
break;
|
||||
case "Home":
|
||||
toggleIsListOpen(false);
|
||||
virtualFocus.moveFocusToTop();
|
||||
break;
|
||||
case "End":
|
||||
toggleIsListOpen(true);
|
||||
virtualFocus.moveFocusToBottom();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -202,6 +194,24 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
}
|
||||
virtualFocus.moveFocusUp();
|
||||
}
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
virtualFocus.moveFocusToTop();
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
if (virtualFocus.activeElement === null || !isListOpen) {
|
||||
toggleIsListOpen(true);
|
||||
}
|
||||
virtualFocus.moveFocusToBottom();
|
||||
} else if (e.key === "PageUp") {
|
||||
e.preventDefault();
|
||||
virtualFocus.moveFocusUpBy(6);
|
||||
} else if (e.key === "PageDown") {
|
||||
e.preventDefault();
|
||||
if (virtualFocus.activeElement === null || !isListOpen) {
|
||||
toggleIsListOpen(true);
|
||||
}
|
||||
virtualFocus.moveFocusDownBy(6);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -230,10 +240,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
} else if (filteredOptions.length === 0) {
|
||||
toggleIsListOpen(false);
|
||||
}
|
||||
virtualFocus.moveFocusToTop();
|
||||
onChange(newValue);
|
||||
},
|
||||
[filteredOptions.length, virtualFocus, onChange, toggleIsListOpen],
|
||||
[filteredOptions.length, onChange, toggleIsListOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -291,4 +291,40 @@ describe("Render combobox", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("has keyboard navigation", () => {
|
||||
test("for PageDown and PageUp", async () => {
|
||||
render(<App options={options} />);
|
||||
|
||||
const combobox = screen.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
});
|
||||
|
||||
const pressKey = async (key: string) => {
|
||||
await act(async () => {
|
||||
await userEvent.keyboard(`{${key}}`);
|
||||
});
|
||||
};
|
||||
|
||||
const hasVirtualFocus = (option: string) =>
|
||||
expect(combobox.getAttribute("aria-activedescendant")).toBe(
|
||||
screen.getByRole("option", { name: option }).id,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(combobox);
|
||||
});
|
||||
|
||||
await pressKey("ArrowDown");
|
||||
hasVirtualFocus("apple");
|
||||
await pressKey("PageDown");
|
||||
hasVirtualFocus("mango");
|
||||
await pressKey("PageDown");
|
||||
hasVirtualFocus("watermelon");
|
||||
await pressKey("PageUp");
|
||||
hasVirtualFocus("mango");
|
||||
await pressKey("PageUp");
|
||||
hasVirtualFocus("apple");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import faker from "faker";
|
||||
import React from "react";
|
||||
import { expect, test } from "vitest";
|
||||
import { ConfirmationPanel } from ".";
|
||||
|
||||
test("omits error id from input", async () => {
|
||||
const label = faker.datatype.string();
|
||||
const label = "My label";
|
||||
|
||||
render(<ConfirmationPanel label={label} errorId="wat"></ConfirmationPanel>);
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import faker from "faker";
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Radio, RadioGroup } from ".";
|
||||
|
||||
const value1 = faker.datatype.string();
|
||||
const label1 = faker.datatype.string();
|
||||
const value2 = faker.datatype.string();
|
||||
const label2 = faker.datatype.string();
|
||||
const value1 = "My first value";
|
||||
const label1 = "World's best radio label";
|
||||
const value2 = "Life changing value";
|
||||
const label2 = "Radio label of the year";
|
||||
|
||||
const Group = (props) => (
|
||||
<RadioGroup {...props} legend="legend">
|
||||
|
||||
@@ -41,8 +41,8 @@ export const useRadio = (props: RadioProps) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
props.onChange && props.onChange(e);
|
||||
radioGroup?.onChange && radioGroup.onChange(props.value);
|
||||
props.onChange?.(e);
|
||||
radioGroup?.onChange?.(props.value);
|
||||
},
|
||||
onClick: (e) => {
|
||||
if (readOnly) {
|
||||
|
||||
@@ -144,7 +144,7 @@ export const Search = forwardRef<HTMLInputElement, SearchProps>(
|
||||
(event: SearchClearEvent) => {
|
||||
onClear?.(event);
|
||||
handleChange("");
|
||||
searchRef.current && searchRef.current?.focus?.();
|
||||
searchRef.current?.focus?.();
|
||||
},
|
||||
[handleChange, onClear],
|
||||
);
|
||||
|
||||
@@ -122,7 +122,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
|
||||
return;
|
||||
}
|
||||
setChecked(e.target.checked);
|
||||
props.onChange && props.onChange(e);
|
||||
props.onChange?.(e);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (readOnly) {
|
||||
|
||||
@@ -49,7 +49,7 @@ Default.argTypes = {
|
||||
export const Open: StoryFn = () => {
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
ref.current && ref.current.click();
|
||||
ref.current?.click();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
export { Accordion, type AccordionProps } from "./accordion";
|
||||
export { ActionMenu, type ActionMenuProps } from "./overlays/action-menu";
|
||||
export { Alert, type AlertProps } from "./alert";
|
||||
export { Button, type ButtonProps } from "./button";
|
||||
export { Chat, type ChatProps } from "./chat";
|
||||
|
||||
@@ -5,6 +5,9 @@ import { getResponsiveProps, getResponsiveValue } from "../utilities/css";
|
||||
import { ResponsiveProp, SpacingScale } from "../utilities/types";
|
||||
|
||||
export type PrimitiveProps = {
|
||||
/**
|
||||
* @private Hides prop from documentation
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Padding around children.
|
||||
|
||||
@@ -19,42 +19,41 @@ import {
|
||||
SurfaceColorToken,
|
||||
} from "../utilities/types";
|
||||
|
||||
export type BoxProps = PrimitiveProps &
|
||||
PrimitiveAsChildProps &
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
/**
|
||||
* CSS `background-color` property.
|
||||
* Accepts a [background/surface color token](https://aksel.nav.no/grunnleggende/styling/design-tokens#afff774dad80).
|
||||
*/
|
||||
background?: BackgroundColorToken | SurfaceColorToken;
|
||||
/**
|
||||
* CSS `border-color` property.
|
||||
* Accepts a [border color token](https://aksel.nav.no/grunnleggende/styling/design-tokens#adb1767e2f87).
|
||||
*/
|
||||
borderColor?: BorderColorToken;
|
||||
/**
|
||||
* CSS `border-radius` property.
|
||||
* Accepts a [radius token](https://aksel.nav.no/grunnleggende/styling/design-tokens#6d79c5605d31)
|
||||
* or an object of radius tokens for different breakpoints.
|
||||
* @example
|
||||
* borderRadius='full'
|
||||
* borderRadius='0 full large small'
|
||||
* borderRadius={{xs: 'small large', sm: '0', md: 'large', lg: 'full'}}
|
||||
*/
|
||||
borderRadius?: ResponsiveProp<SpaceDelimitedAttribute<BorderRadiiToken>>;
|
||||
/**
|
||||
* CSS `border-width` property. If this is not set there will be no border.
|
||||
* @example
|
||||
* borderWidth='2'
|
||||
* borderWidth='1 2 3 4'
|
||||
*/
|
||||
borderWidth?: SpaceDelimitedAttribute<"0" | "1" | "2" | "3" | "4" | "5">;
|
||||
/** Shadow on box. Accepts a shadow token.
|
||||
* @example
|
||||
* shadow='small'
|
||||
*/
|
||||
shadow?: ShadowToken;
|
||||
};
|
||||
export type BoxProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
/**
|
||||
* CSS `background-color` property.
|
||||
* Accepts a [background/surface color token](https://aksel.nav.no/grunnleggende/styling/design-tokens#afff774dad80).
|
||||
*/
|
||||
background?: BackgroundColorToken | SurfaceColorToken;
|
||||
/**
|
||||
* CSS `border-color` property.
|
||||
* Accepts a [border color token](https://aksel.nav.no/grunnleggende/styling/design-tokens#adb1767e2f87).
|
||||
*/
|
||||
borderColor?: BorderColorToken;
|
||||
/**
|
||||
* CSS `border-radius` property.
|
||||
* Accepts a [radius token](https://aksel.nav.no/grunnleggende/styling/design-tokens#6d79c5605d31)
|
||||
* or an object of radius tokens for different breakpoints.
|
||||
* @example
|
||||
* borderRadius='full'
|
||||
* borderRadius='0 full large small'
|
||||
* borderRadius={{xs: 'small large', sm: '0', md: 'large', lg: 'full'}}
|
||||
*/
|
||||
borderRadius?: ResponsiveProp<SpaceDelimitedAttribute<BorderRadiiToken>>;
|
||||
/**
|
||||
* CSS `border-width` property. If this is not set there will be no border.
|
||||
* @example
|
||||
* borderWidth='2'
|
||||
* borderWidth='1 2 3 4'
|
||||
*/
|
||||
borderWidth?: SpaceDelimitedAttribute<"0" | "1" | "2" | "3" | "4" | "5">;
|
||||
/** Shadow on box. Accepts a shadow token.
|
||||
* @example
|
||||
* shadow='small'
|
||||
*/
|
||||
shadow?: ShadowToken;
|
||||
} & PrimitiveProps &
|
||||
PrimitiveAsChildProps;
|
||||
|
||||
/**
|
||||
* Foundational Layout-primitive for generic encapsulation & styling.
|
||||
|
||||
@@ -10,33 +10,32 @@ import { PrimitiveAsChildProps } from "../base/PrimitiveAsChildProps";
|
||||
import { getResponsiveProps, getResponsiveValue } from "../utilities/css";
|
||||
import { ResponsiveProp, SpacingScale } from "../utilities/types";
|
||||
|
||||
export type HGridProps = PrimitiveProps &
|
||||
PrimitiveAsChildProps &
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
/**
|
||||
* Number of columns to display. Can be a number, a string, or an object with values for specific breakpoints.
|
||||
* Sets `grid-template-columns`, so `fr`, `minmax` etc. works.
|
||||
* @example
|
||||
* columns={{ sm: 1, md: 1, lg: "1fr auto", xl: "1fr auto"}}
|
||||
* columns={3}
|
||||
* columns="repeat(3, minmax(0, 1fr))"
|
||||
*/
|
||||
columns?: ResponsiveProp<number | string>;
|
||||
/**
|
||||
* Spacing between columns.
|
||||
* Accepts a [spacing token](https://aksel.nav.no/grunnleggende/styling/design-tokens#0cc9fb32f213)
|
||||
* or an object of spacing tokens for different breakpoints.
|
||||
* @example
|
||||
* gap="6"
|
||||
* gap="8 4"
|
||||
* gap={{ sm: "2", md: "2", lg: "6", xl: "6"}}
|
||||
*/
|
||||
gap?: ResponsiveProp<SpacingScale | `${SpacingScale} ${SpacingScale}`>;
|
||||
/**
|
||||
* Vertical alignment of children. Elements will by default stretch to the height of parent-element.
|
||||
*/
|
||||
align?: "start" | "center" | "end";
|
||||
};
|
||||
export type HGridProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
/**
|
||||
* Number of columns to display. Can be a number, a string, or an object with values for specific breakpoints.
|
||||
* Sets `grid-template-columns`, so `fr`, `minmax` etc. works.
|
||||
* @example
|
||||
* columns={{ sm: 1, md: 1, lg: "1fr auto", xl: "1fr auto"}}
|
||||
* columns={3}
|
||||
* columns="repeat(3, minmax(0, 1fr))"
|
||||
*/
|
||||
columns?: ResponsiveProp<number | string>;
|
||||
/**
|
||||
* Spacing between columns.
|
||||
* Accepts a [spacing token](https://aksel.nav.no/grunnleggende/styling/design-tokens#0cc9fb32f213)
|
||||
* or an object of spacing tokens for different breakpoints.
|
||||
* @example
|
||||
* gap="6"
|
||||
* gap="8 4"
|
||||
* gap={{ sm: "2", md: "2", lg: "6", xl: "6"}}
|
||||
*/
|
||||
gap?: ResponsiveProp<SpacingScale | `${SpacingScale} ${SpacingScale}`>;
|
||||
/**
|
||||
* Vertical alignment of children. Elements will by default stretch to the height of parent-element.
|
||||
*/
|
||||
align?: "start" | "center" | "end";
|
||||
} & PrimitiveProps &
|
||||
PrimitiveAsChildProps;
|
||||
/**
|
||||
* Horizontal Grid Primitive with dynamic columns and gap based on breakpoints.
|
||||
*
|
||||
|
||||
@@ -11,60 +11,59 @@ import { PrimitiveAsChildProps } from "../base/PrimitiveAsChildProps";
|
||||
import { getResponsiveProps, getResponsiveValue } from "../utilities/css";
|
||||
import { ResponsiveProp, SpacingScale } from "../utilities/types";
|
||||
|
||||
export type StackProps = PrimitiveProps &
|
||||
PrimitiveAsChildProps &
|
||||
HTMLAttributes<HTMLDivElement> & {
|
||||
/**
|
||||
* CSS `justify-content` property.
|
||||
*
|
||||
* @example
|
||||
* justify='center'
|
||||
* justify={{xs: 'start', sm: 'center', md: 'end', lg: 'space-around', xl: 'space-between'}}
|
||||
*/
|
||||
justify?: ResponsiveProp<
|
||||
| "start"
|
||||
| "center"
|
||||
| "end"
|
||||
| "space-around"
|
||||
| "space-between"
|
||||
| "space-evenly"
|
||||
>;
|
||||
/**
|
||||
* CSS `align-items` property.
|
||||
* @default "stretch"
|
||||
*
|
||||
* @example
|
||||
* align='center'
|
||||
* align={{xs: 'start', sm: 'center', md: 'end', lg: 'baseline', xl: 'stretch'}}
|
||||
*/
|
||||
align?: ResponsiveProp<"start" | "center" | "end" | "baseline" | "stretch">;
|
||||
/**
|
||||
* Sets the CSS `flex-wrap` property.
|
||||
*/
|
||||
wrap?: boolean;
|
||||
/**
|
||||
* CSS `gap` property.
|
||||
* Accepts a [spacing token](https://aksel.nav.no/grunnleggende/styling/design-tokens#0cc9fb32f213)
|
||||
* or an object of spacing tokens for different breakpoints.
|
||||
*
|
||||
* @example
|
||||
* gap='4'
|
||||
* gap='8 4'
|
||||
* gap={{xs: '2', sm: '3', md: '4', lg: '5', xl: '6'}}
|
||||
*/
|
||||
gap?: ResponsiveProp<SpacingScale | `${SpacingScale} ${SpacingScale}`>;
|
||||
/**
|
||||
* CSS `flex-direction` property.
|
||||
* @default "row"
|
||||
*
|
||||
* @example
|
||||
* direction='row'
|
||||
* direction={{xs: 'row', sm: 'column'}}
|
||||
*/
|
||||
direction?: ResponsiveProp<
|
||||
"row" | "column" | "row-reverse" | "column-reverse"
|
||||
>;
|
||||
};
|
||||
export type StackProps = HTMLAttributes<HTMLDivElement> & {
|
||||
/**
|
||||
* CSS `justify-content` property.
|
||||
*
|
||||
* @example
|
||||
* justify='center'
|
||||
* justify={{xs: 'start', sm: 'center', md: 'end', lg: 'space-around', xl: 'space-between'}}
|
||||
*/
|
||||
justify?: ResponsiveProp<
|
||||
| "start"
|
||||
| "center"
|
||||
| "end"
|
||||
| "space-around"
|
||||
| "space-between"
|
||||
| "space-evenly"
|
||||
>;
|
||||
/**
|
||||
* CSS `align-items` property.
|
||||
* @default "stretch"
|
||||
*
|
||||
* @example
|
||||
* align='center'
|
||||
* align={{xs: 'start', sm: 'center', md: 'end', lg: 'baseline', xl: 'stretch'}}
|
||||
*/
|
||||
align?: ResponsiveProp<"start" | "center" | "end" | "baseline" | "stretch">;
|
||||
/**
|
||||
* Sets the CSS `flex-wrap` property.
|
||||
*/
|
||||
wrap?: boolean;
|
||||
/**
|
||||
* CSS `gap` property.
|
||||
* Accepts a [spacing token](https://aksel.nav.no/grunnleggende/styling/design-tokens#0cc9fb32f213)
|
||||
* or an object of spacing tokens for different breakpoints.
|
||||
*
|
||||
* @example
|
||||
* gap='4'
|
||||
* gap='8 4'
|
||||
* gap={{xs: '2', sm: '3', md: '4', lg: '5', xl: '6'}}
|
||||
*/
|
||||
gap?: ResponsiveProp<SpacingScale | `${SpacingScale} ${SpacingScale}`>;
|
||||
/**
|
||||
* CSS `flex-direction` property.
|
||||
* @default "row"
|
||||
*
|
||||
* @example
|
||||
* direction='row'
|
||||
* direction={{xs: 'row', sm: 'column'}}
|
||||
*/
|
||||
direction?: ResponsiveProp<
|
||||
"row" | "column" | "row-reverse" | "column-reverse"
|
||||
>;
|
||||
} & PrimitiveProps &
|
||||
PrimitiveAsChildProps;
|
||||
|
||||
export const Stack: OverridableComponent<StackProps, HTMLDivElement> =
|
||||
forwardRef(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import {
|
||||
BrailleIcon,
|
||||
CheckmarkIcon,
|
||||
ForkIcon,
|
||||
HeadHeartIcon,
|
||||
@@ -322,7 +321,19 @@ export const Icons: Story = {
|
||||
<List title="Best title">
|
||||
<List.Item
|
||||
title="Lorem Ipsum Dolor Sit Amet"
|
||||
icon={<BrailleIcon aria-hidden />}
|
||||
icon={
|
||||
<svg
|
||||
width="12"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ transform: "rotate(45deg)" }}
|
||||
aria-hidden
|
||||
>
|
||||
<rect width="24" height="24" rx="6" fill="var(--a-icon-alt-3)" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Beskrivelsen på punkter er nærmere forklart <a href="/">her</a>
|
||||
</List.Item>
|
||||
|
||||
@@ -25,6 +25,12 @@ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(
|
||||
className="navds-modal__button"
|
||||
size="small"
|
||||
variant="tertiary-neutral"
|
||||
onKeyDown={(event) => {
|
||||
/* Prevents autofocus used in combination with holding down keys from closing modal */
|
||||
if (["Enter", " "].includes(event.key) && event.repeat) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onClick={context.closeHandler}
|
||||
icon={<XMarkIcon title="Lukk" />}
|
||||
/>
|
||||
|
||||
@@ -518,7 +518,7 @@ dialogPolyfill.isInlinePositionSetByStylesheet = function (element) {
|
||||
// Some browsers throw on cssRules.
|
||||
try {
|
||||
cssRules = styleSheet.cssRules;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
if (!cssRules) {
|
||||
@@ -530,7 +530,7 @@ dialogPolyfill.isInlinePositionSetByStylesheet = function (element) {
|
||||
// Ignore errors on invalid selector texts.
|
||||
try {
|
||||
selectedNodes = document.querySelectorAll(rule.selectorText);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
if (!selectedNodes || !inNodeList(selectedNodes, element)) {
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { PencilIcon, StarIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../../button";
|
||||
import { HStack, VStack } from "../../layout/stack";
|
||||
import { Modal } from "../../modal";
|
||||
import { Tooltip } from "../../tooltip";
|
||||
import { BodyShort } from "../../typography";
|
||||
import { ActionMenu } from "./ActionMenu";
|
||||
|
||||
export default {
|
||||
title: "ds-react/ActionMenu",
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof ActionMenu>;
|
||||
|
||||
type Story = StoryObj<typeof ActionMenu>;
|
||||
|
||||
const DemoDecorator: Story["decorators"] = (Story, { name }) => {
|
||||
return (
|
||||
<VStack gap="4" align="start">
|
||||
<h2>{name}</h2>
|
||||
<p>Placeholder before button</p>
|
||||
<button>Focusable item before action</button>
|
||||
<Story />
|
||||
<button>Focusable item after action</button>
|
||||
<p>Placeholder after button</p>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export const OnlyItems: Story = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<ActionMenu open={props.open}>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 1 clicked")}>
|
||||
Item 1
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 2 clicked")}>
|
||||
Item 2
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 3 clicked")}>
|
||||
Item 3
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const GroupedItems: Story = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<ActionMenu open={props.open}>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.Group label="Group 1">
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 1 clicked")}>
|
||||
Item 1
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 2 clicked")}>
|
||||
Item 2
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 3 clicked")}>
|
||||
Item 3
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Group>
|
||||
<ActionMenu.Group label="Group 2">
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 4 clicked")}>
|
||||
Item 4
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 5 clicked")}>
|
||||
Item 5
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 6 clicked")}>
|
||||
Item 6
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Group>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const ShortcutsAndIcons: Story = {
|
||||
render: (props) => {
|
||||
const [checkedItems, setCheckedItems] = useState({
|
||||
checkbox1: false,
|
||||
checkbox2: false,
|
||||
});
|
||||
|
||||
const handleCheckboxChange = (checkboxId: string) => {
|
||||
setCheckedItems((prevState) => ({
|
||||
...prevState,
|
||||
[checkboxId]: !prevState[checkboxId],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionMenu open={props.open}>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
<ActionMenu.Content>
|
||||
<VStack asChild align="start" gap="1" paddingBlock="2">
|
||||
<ActionMenu.Label>
|
||||
<BodyShort weight="semibold" as="div">
|
||||
Ola Normann
|
||||
</BodyShort>
|
||||
<div>D123456</div>
|
||||
</ActionMenu.Label>
|
||||
</VStack>
|
||||
<ActionMenu.Divider />
|
||||
<ActionMenu.Group label="Group 1">
|
||||
<ActionMenu.CheckboxItem
|
||||
checked={checkedItems.checkbox1}
|
||||
onCheckedChange={() => handleCheckboxChange("checkbox1")}
|
||||
shortcut="⌘+T"
|
||||
>
|
||||
Checkbox 1
|
||||
</ActionMenu.CheckboxItem>
|
||||
<ActionMenu.CheckboxItem
|
||||
checked={checkedItems.checkbox2}
|
||||
onCheckedChange={() => handleCheckboxChange("checkbox2")}
|
||||
shortcut="⇧+⌘+N"
|
||||
>
|
||||
Checkbox 2
|
||||
</ActionMenu.CheckboxItem>
|
||||
</ActionMenu.Group>
|
||||
<ActionMenu.Divider />
|
||||
<ActionMenu.Group label="Group 2">
|
||||
<ActionMenu.Item
|
||||
shortcut="⌘+T"
|
||||
onSelect={() => console.log("Item 1 clicked")}
|
||||
icon={<StarIcon aria-hidden />}
|
||||
>
|
||||
Item 1
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item
|
||||
shortcut="⇧+⌘+N"
|
||||
onSelect={() => console.log("Item 2 clicked")}
|
||||
icon={<PencilIcon aria-hidden />}
|
||||
>
|
||||
Item 2
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Group>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const Checkboxes: Story = {
|
||||
render: (props) => {
|
||||
const [checkedItems, setCheckedItems] = useState({
|
||||
checkbox1: false,
|
||||
checkbox2: true,
|
||||
checkbox3: false,
|
||||
checkbox4: false,
|
||||
});
|
||||
|
||||
const handleCheckboxChange = (checkboxId: string) => {
|
||||
setCheckedItems((prevState) => ({
|
||||
...prevState,
|
||||
[checkboxId]: !prevState[checkboxId],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionMenu open={props.open}>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.CheckboxItem
|
||||
checked={
|
||||
Object.values(checkedItems).every(Boolean)
|
||||
? true
|
||||
: Object.values(checkedItems).some(Boolean)
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={() =>
|
||||
Object.values(checkedItems).every(Boolean)
|
||||
? setCheckedItems((prevState) =>
|
||||
Object.keys(prevState).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = false;
|
||||
return acc;
|
||||
},
|
||||
{} as typeof checkedItems,
|
||||
),
|
||||
)
|
||||
: setCheckedItems((prevState) =>
|
||||
Object.keys(prevState).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
},
|
||||
{} as typeof checkedItems,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Select all
|
||||
</ActionMenu.CheckboxItem>
|
||||
<ActionMenu.Group label="Group 1">
|
||||
<ActionMenu.CheckboxItem
|
||||
checked={checkedItems.checkbox1}
|
||||
onCheckedChange={() => handleCheckboxChange("checkbox1")}
|
||||
>
|
||||
Checkbox 1
|
||||
</ActionMenu.CheckboxItem>
|
||||
<ActionMenu.CheckboxItem
|
||||
checked={checkedItems.checkbox2}
|
||||
onCheckedChange={() => handleCheckboxChange("checkbox2")}
|
||||
>
|
||||
Checkbox 1
|
||||
</ActionMenu.CheckboxItem>
|
||||
</ActionMenu.Group>
|
||||
<ActionMenu.Group label="Group 2">
|
||||
<ActionMenu.CheckboxItem
|
||||
checked={checkedItems.checkbox3}
|
||||
onCheckedChange={() => handleCheckboxChange("checkbox3")}
|
||||
>
|
||||
Checkbox 3
|
||||
</ActionMenu.CheckboxItem>
|
||||
<ActionMenu.CheckboxItem
|
||||
checked={checkedItems.checkbox4}
|
||||
onCheckedChange={() => handleCheckboxChange("checkbox4")}
|
||||
>
|
||||
Checkbox 4
|
||||
</ActionMenu.CheckboxItem>
|
||||
</ActionMenu.Group>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const RadioGroups: Story = {
|
||||
render: (props) => {
|
||||
const [group1Value, setGroup1Value] = useState("1");
|
||||
const [group2Value, setGroup2Value] = useState("4");
|
||||
|
||||
return (
|
||||
<ActionMenu open={props.open}>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.RadioGroup
|
||||
onValueChange={setGroup1Value}
|
||||
value={group1Value}
|
||||
label="Group 1"
|
||||
>
|
||||
<ActionMenu.RadioItem value="1">Radio 1</ActionMenu.RadioItem>
|
||||
<ActionMenu.RadioItem value="2">Radio 2</ActionMenu.RadioItem>
|
||||
</ActionMenu.RadioGroup>
|
||||
<ActionMenu.RadioGroup
|
||||
onValueChange={setGroup2Value}
|
||||
value={group2Value}
|
||||
label="Group 2"
|
||||
>
|
||||
<ActionMenu.RadioItem value="3">Radio 3</ActionMenu.RadioItem>
|
||||
<ActionMenu.RadioItem value="4">Radio 4</ActionMenu.RadioItem>
|
||||
</ActionMenu.RadioGroup>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const Dividers: Story = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<ActionMenu open={props.open}>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.Group label="Group 1">
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 1 clicked")}>
|
||||
Item 1
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Group>
|
||||
<ActionMenu.Divider />
|
||||
<ActionMenu.Group label="Group 2">
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 4 clicked")}>
|
||||
Item 4
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Group>
|
||||
<ActionMenu.Divider />
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 7 clicked")}>
|
||||
Item 7
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Divider />
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 8 clicked")}>
|
||||
Item 8
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const Submenus: Story = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<ActionMenu open={props.open}>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 1 clicked")}>
|
||||
Item 1
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Sub open={props.open}>
|
||||
<ActionMenu.SubTrigger>Submenu 1</ActionMenu.SubTrigger>
|
||||
<ActionMenu.SubContent>
|
||||
<ActionMenu.Item
|
||||
onSelect={() => console.log("Subitem 1 clicked")}
|
||||
>
|
||||
Subitem 1
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item
|
||||
onSelect={() => console.log("Subitem 2 clicked")}
|
||||
>
|
||||
Subitem 2
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Sub open={props.open}>
|
||||
<ActionMenu.SubTrigger>Nested submenu 1</ActionMenu.SubTrigger>
|
||||
<ActionMenu.SubContent>
|
||||
<ActionMenu.Item
|
||||
onSelect={() => console.log("Nested Subitem 1 clicked")}
|
||||
>
|
||||
Nested Subitem 1
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item
|
||||
onSelect={() => console.log("Nested Subitem 2 clicked")}
|
||||
>
|
||||
Nested Subitem 2
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.SubContent>
|
||||
</ActionMenu.Sub>
|
||||
</ActionMenu.SubContent>
|
||||
</ActionMenu.Sub>
|
||||
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 3 clicked")}>
|
||||
Item 3
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<ActionMenu open={props.open}>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.Item disabled shortcut="T+W">
|
||||
Item 1
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Sub>
|
||||
<ActionMenu.SubTrigger disabled>Submenu 1</ActionMenu.SubTrigger>
|
||||
<ActionMenu.SubContent>
|
||||
<ActionMenu.Item
|
||||
onSelect={() => console.log("Subitem 1 clicked")}
|
||||
>
|
||||
Subitem 1
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.SubContent>
|
||||
</ActionMenu.Sub>
|
||||
|
||||
<ActionMenu.CheckboxItem checked disabled shortcut="T+W">
|
||||
Checkbox disabled
|
||||
</ActionMenu.CheckboxItem>
|
||||
<ActionMenu.CheckboxItem>Checkbox 2</ActionMenu.CheckboxItem>
|
||||
<ActionMenu.RadioGroup value="1" label="Radiogroup">
|
||||
<ActionMenu.RadioItem disabled value="1">
|
||||
Radio disabled
|
||||
</ActionMenu.RadioItem>
|
||||
<ActionMenu.RadioItem value="2">Radio</ActionMenu.RadioItem>
|
||||
</ActionMenu.RadioGroup>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const TriggerWithTooltip: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<ActionMenu>
|
||||
<Tooltip content="Tooltip!">
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
</Tooltip>
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 1 clicked")}>
|
||||
Item 1
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 2 clicked")}>
|
||||
Item 2
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 3 clicked")}>
|
||||
Item 3
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const ModalTrigger: Story = {
|
||||
render: () => {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ActionMenu>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.Item onSelect={() => ref.current?.showModal()}>
|
||||
open modal
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 2 clicked")}>
|
||||
Item 2
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
<Modal ref={ref} header={{ heading: "Heading" }}>
|
||||
<Modal.Body>
|
||||
Culpa aliquip ut cupidatat laborum minim quis ex in aliqua.
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button type="button" onClick={() => ref.current?.close()}>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const OpenInsideModal: Story = {
|
||||
render: () => {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.showModal();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => ref.current?.showModal()}>Open modal</button>
|
||||
<Modal ref={ref} header={{ heading: "Heading" }}>
|
||||
<Modal.Body>
|
||||
Culpa aliquip ut cupidatat laborum minim quis ex in aliqua.
|
||||
<ActionMenu>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 1 clicked")}>
|
||||
Item 1
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 2 clicked")}>
|
||||
Item 2
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onSelect={() => console.log("Item 3 clicked")}>
|
||||
Item 3
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
<ActionMenu.Item>Item</ActionMenu.Item>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button type="button" onClick={() => ref.current?.close()}>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const Links: Story = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<ActionMenu open={props.open}>
|
||||
<ActionMenu.Trigger>
|
||||
<button>Open action</button>
|
||||
</ActionMenu.Trigger>
|
||||
<ActionMenu.Content>
|
||||
<ActionMenu.Item as="a" href="#1">
|
||||
Item 1
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item as="a" href="#2">
|
||||
Item 2
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item as="a" href="#3">
|
||||
Item 3
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Content>
|
||||
</ActionMenu>
|
||||
);
|
||||
},
|
||||
decorators: [DemoDecorator],
|
||||
};
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: (args, context) => {
|
||||
const newArgs = { ...args, open: true };
|
||||
return (
|
||||
<VStack style={{ gap: "32rem" }}>
|
||||
<div>
|
||||
<h2>Submenus</h2>
|
||||
{Submenus.render?.(newArgs, context)}
|
||||
</div>
|
||||
<HStack gap="12">
|
||||
<div>
|
||||
<h2>OnlyItems</h2>
|
||||
{OnlyItems.render?.(newArgs, context)}
|
||||
</div>
|
||||
<div>
|
||||
<h2>GroupedItems</h2>
|
||||
{GroupedItems.render?.(newArgs, context)}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Shortcut</h2>
|
||||
{ShortcutsAndIcons.render?.(newArgs, context)}
|
||||
</div>
|
||||
</HStack>
|
||||
<HStack gap="12">
|
||||
<div>
|
||||
<h2>Checkboxes</h2>
|
||||
{Checkboxes.render?.(newArgs, context)}
|
||||
</div>
|
||||
<div>
|
||||
<h2>RadioGroups</h2>
|
||||
{RadioGroups.render?.(newArgs, context)}
|
||||
</div>
|
||||
</HStack>
|
||||
<div>
|
||||
<h2>Dividers</h2>
|
||||
{Dividers.render?.(newArgs, context)}
|
||||
</div>
|
||||
|
||||
<HStack gap="12">
|
||||
<div>
|
||||
<h2>Links</h2>
|
||||
{Links.render?.(newArgs, context)}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Disabled</h2>
|
||||
{Disabled.render?.(newArgs, context)}
|
||||
</div>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,971 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import { ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { useModalContext } from "../../modal/Modal.context";
|
||||
import { Slot } from "../../slot/Slot";
|
||||
import { OverridableComponent, useId } from "../../util";
|
||||
import { composeEventHandlers } from "../../util/composeEventHandlers";
|
||||
import { createContext } from "../../util/create-context";
|
||||
import { useMergeRefs } from "../../util/hooks";
|
||||
import { useControllableState } from "../../util/hooks/useControllableState";
|
||||
import { requireReactElement } from "../../util/requireReactElement";
|
||||
import { Menu, MenuPortalProps } from "../floating-menu/Menu";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenu */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type ActionMenuContextValue = {
|
||||
triggerId: string;
|
||||
triggerRef: React.RefObject<HTMLButtonElement>;
|
||||
contentId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onOpenToggle: () => void;
|
||||
rootElement: MenuPortalProps["rootElement"];
|
||||
};
|
||||
|
||||
const [ActionMenuProvider, useActionMenuContext] =
|
||||
createContext<ActionMenuContextValue>({
|
||||
name: "ActionMenuContext",
|
||||
errorMessage:
|
||||
"ActionMenu sub-components cannot be rendered outside the ActionMenu component.",
|
||||
});
|
||||
|
||||
type ActionMenuProps = {
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Whether the menu is open or not.
|
||||
* Only needed if you want manually control state.
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Callback for when the menu is opened or closed.
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
} & Pick<MenuPortalProps, "rootElement">;
|
||||
|
||||
interface ActionMenuComponent extends React.FC<ActionMenuProps> {
|
||||
/**
|
||||
* Acts as a trigger and anchor for the menu.
|
||||
* Must be wrapped around a button. If you use your own component, make sure to forward ref and props.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.Trigger>
|
||||
* <button>Open Menu</button>
|
||||
* </ActionMenu.Trigger>
|
||||
* ```
|
||||
*/
|
||||
Trigger: typeof ActionMenuTrigger;
|
||||
/**
|
||||
* The menu content, containing all the items.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.Content>
|
||||
* <ActionMenu.Item>
|
||||
* Item 1
|
||||
* </ActionMenu.Item>
|
||||
* <ActionMenu.Item>
|
||||
* Item 2
|
||||
* </ActionMenu.Item>
|
||||
* </ActionMenu.Content>
|
||||
* ```
|
||||
*/
|
||||
Content: typeof ActionMenuContent;
|
||||
/**
|
||||
* Semantically and visually groups items together with a label.
|
||||
* This is the prefered way to group items, as it provides better accessibility
|
||||
* rather than using a standalone `ActionMenu.Label`.
|
||||
*
|
||||
* It is required to use either `label` or `aria-label` to provide an accessible name for the group.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.Content>
|
||||
* <ActionMenu.Group label="Group 1">
|
||||
* <ActionMenu.Item>
|
||||
* Item 1
|
||||
* </ActionMenu.Item>
|
||||
* <ActionMenu.Item>
|
||||
* Item 2
|
||||
* </ActionMenu.Item>
|
||||
* </ActionMenu.Group>
|
||||
* <ActionMenu.Group label="Group 2">
|
||||
* <ActionMenu.Item>
|
||||
* Item 3
|
||||
* </ActionMenu.Item>
|
||||
* <ActionMenu.Item>
|
||||
* Item 4
|
||||
* </ActionMenu.Item>
|
||||
* </ActionMenu.Group>
|
||||
* </ActionMenu.Content>
|
||||
* ```
|
||||
*/
|
||||
Group: typeof ActionMenuGroup;
|
||||
/**
|
||||
* Separate labeling option for the menu.
|
||||
* This is not for grouping items, but rather for adding a label to the menu at the top. For grouping items, use `ActionMenu.Group`.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.Content>
|
||||
* <ActionMenu.Label>
|
||||
* Label
|
||||
* </ActionMenu.Label>
|
||||
* <ActionMenu.Divider />
|
||||
* </ActionMenu.Content
|
||||
* ```
|
||||
*/
|
||||
Label: typeof ActionMenuLabel;
|
||||
/**
|
||||
* A single item in the menu. Can be used standalone or grouped with other items.
|
||||
* Use `onSelect` to handle the action when the item is selected, like navigating to a new page or performing an action.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.Content>
|
||||
* // Grouped
|
||||
* <ActionMenu.Group label="Group 1">
|
||||
* <ActionMenu.Item onSelect={navigate}>
|
||||
* Item 1
|
||||
* </ActionMenu.Item>
|
||||
* <ActionMenu.Item onSelect={navigate}>
|
||||
* Item 2
|
||||
* </ActionMenu.Item>
|
||||
* </ActionMenu.Group>
|
||||
* <ActionMenu.Divider />
|
||||
* // Standalone
|
||||
* <ActionMenu.Item onSelect={updateState}>
|
||||
* Item 3
|
||||
* </ActionMenu.Item>
|
||||
* </ActionMenu.Content>
|
||||
* ```
|
||||
* @example As link
|
||||
* ```jsx
|
||||
* <ActionMenu.Item as="a" href="#">
|
||||
* Item
|
||||
* </ActionMenu.Item>
|
||||
* ```
|
||||
*/
|
||||
Item: typeof ActionMenuItem;
|
||||
/**
|
||||
* A checkbox item in the menu. Can be used standalone or grouped with other items.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.CheckboxItem
|
||||
* checked={isChecked}
|
||||
* onCheckedChange={handleChange}
|
||||
* >
|
||||
* Checkbox 1
|
||||
* </ActionMenu.CheckboxItem>
|
||||
* ```
|
||||
*/
|
||||
CheckboxItem: typeof ActionMenuCheckboxItem;
|
||||
/**
|
||||
* A radio group in the menu.
|
||||
*
|
||||
* It is required to use either `label` or `aria-label` to provide an accessible name for the group.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.RadioGroup
|
||||
* onValueChange={handleValueChange}
|
||||
* value={radioValue}
|
||||
* label="Radio group"
|
||||
* >
|
||||
* <ActionMenu.RadioItem value="1">Radio 1</ActionMenu.RadioItem>
|
||||
* <ActionMenu.RadioItem value="2">Radio 2</ActionMenu.RadioItem>
|
||||
* </ActionMenu.RadioGroup>
|
||||
* ```
|
||||
*/
|
||||
RadioGroup: typeof ActionMenuRadioGroup;
|
||||
/**
|
||||
* A radio item in the menu. Should always be grouped with an `ActionMenu.RadioGroup`.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.RadioGroup
|
||||
* onValueChange={handleValueChange}
|
||||
* value={radioValue}
|
||||
* label="Radio group"
|
||||
* >
|
||||
* <ActionMenu.RadioItem value="1">Radio 1</ActionMenu.RadioItem>
|
||||
* <ActionMenu.RadioItem value="2">Radio 2</ActionMenu.RadioItem>
|
||||
* </ActionMenu.RadioGroup>
|
||||
* ```
|
||||
*/
|
||||
RadioItem: typeof ActionMenuRadioItem;
|
||||
/**
|
||||
* A simple divider to separate items in the menu.
|
||||
*/
|
||||
Divider: typeof ActionMenuDivider;
|
||||
/**
|
||||
* A sub-menu that can be nested inside the menu.
|
||||
* The sub-menu can be nested inside other sub-menus allowing for multiple levels of nesting.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.Sub>
|
||||
* <ActionMenu.SubTrigger>Submenu 1</ActionMenu.SubTrigger>
|
||||
* <ActionMenu.SubContent>
|
||||
* <ActionMenu.Item>
|
||||
* Subitem 1
|
||||
* </ActionMenu.Item>
|
||||
* <ActionMenu.Item>
|
||||
* Subitem 2
|
||||
* </ActionMenu.Item>
|
||||
* </ActionMenu.SubContent>
|
||||
* </ActionMenu.Sub>
|
||||
* ```
|
||||
*/
|
||||
Sub: typeof ActionMenuSub;
|
||||
/**
|
||||
* Acts as a trigger for a sub-menu.
|
||||
* In contrast to `ActionMenu.Trigger`, this trigger is a standalone component and should not be wrapped around a React.ReactNode.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.Sub>
|
||||
* <ActionMenu.SubTrigger>Submenu 1</ActionMenu.SubTrigger>
|
||||
* </ActionMenu.Sub>
|
||||
* ```
|
||||
*/
|
||||
SubTrigger: typeof ActionMenuSubTrigger;
|
||||
/**
|
||||
* The content of a sub-menu.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ActionMenu.Sub>
|
||||
* <ActionMenu.SubContent>
|
||||
* <ActionMenu.Item>
|
||||
* Subitem 1
|
||||
* </ActionMenu.Item>
|
||||
* <ActionMenu.Item>
|
||||
* Subitem 2
|
||||
* </ActionMenu.Item>
|
||||
* </ActionMenu.SubContent>
|
||||
* </ActionMenu.Sub>
|
||||
* ```
|
||||
*/
|
||||
SubContent: typeof ActionMenuSubContent;
|
||||
}
|
||||
|
||||
const ActionMenuRoot = ({
|
||||
children,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
rootElement: rootElementProp,
|
||||
}: ActionMenuProps) => {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const modalContext = useModalContext(false);
|
||||
const rootElement = modalContext ? modalContext.ref.current : rootElementProp;
|
||||
|
||||
const [open = false, setOpen] = useControllableState({
|
||||
value: openProp,
|
||||
defaultValue: false,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionMenuProvider
|
||||
triggerId={useId()}
|
||||
triggerRef={triggerRef}
|
||||
contentId={useId()}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOpenToggle={() => setOpen((prevOpen) => !prevOpen)}
|
||||
rootElement={rootElement}
|
||||
>
|
||||
<Menu open={open} onOpenChange={setOpen} modal>
|
||||
{children}
|
||||
</Menu>
|
||||
</ActionMenuProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActionMenu = ActionMenuRoot as ActionMenuComponent;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuTrigger */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
interface ActionMenuTriggerProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export const ActionMenuTrigger = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ActionMenuTriggerProps
|
||||
>(
|
||||
(
|
||||
{ children, onKeyDown, style, onClick, ...rest }: ActionMenuTriggerProps,
|
||||
ref,
|
||||
) => {
|
||||
const context = useActionMenuContext();
|
||||
|
||||
const mergedRefs = useMergeRefs(ref, context.triggerRef);
|
||||
|
||||
return (
|
||||
<Menu.Anchor asChild>
|
||||
<Slot
|
||||
type="button"
|
||||
id={context.triggerId}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={context.open}
|
||||
aria-controls={context.open ? context.contentId : undefined}
|
||||
data-state={context.open ? "open" : "closed"}
|
||||
ref={mergedRefs}
|
||||
{...rest}
|
||||
style={{ ...style, pointerEvents: context.open ? "auto" : undefined }}
|
||||
onClick={composeEventHandlers(onClick, context.onOpenToggle)}
|
||||
onKeyDown={composeEventHandlers(onKeyDown, (event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
context.onOpenChange(true);
|
||||
/* Stop keydown from scrolling window */
|
||||
event.preventDefault();
|
||||
}
|
||||
})}
|
||||
>
|
||||
{requireReactElement(children)}
|
||||
</Slot>
|
||||
</Menu.Anchor>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuContent */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
interface ActionMenuContentProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "id"> {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ActionMenuContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
ActionMenuContentProps
|
||||
>(({ children, className, style, ...rest }: ActionMenuContentProps, ref) => {
|
||||
const context = useActionMenuContext();
|
||||
|
||||
return (
|
||||
<Menu.Portal rootElement={context.rootElement} asChild>
|
||||
<Menu.Content
|
||||
ref={ref}
|
||||
id={context.contentId}
|
||||
aria-labelledby={context.triggerId}
|
||||
className={cl("navds-action-menu__content", className)}
|
||||
{...rest}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={10}
|
||||
onCloseAutoFocus={() => {
|
||||
context.triggerRef.current?.focus();
|
||||
}}
|
||||
safeZone={{ anchor: context.triggerRef.current }}
|
||||
style={{
|
||||
...style,
|
||||
...{
|
||||
"--__ac-action-menu-content-transform-origin":
|
||||
"var(--ac-floating-transform-origin)",
|
||||
"--__ac-action-menu-content-available-height":
|
||||
"var(--ac-floating-available-height)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="navds-action-menu__content-inner">{children}</div>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuLabel */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
interface ActionMenuLabelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ActionMenuLabel = forwardRef<HTMLDivElement, ActionMenuLabelProps>(
|
||||
({ children, className, ...rest }: ActionMenuLabelProps, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-action-menu__label", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuGroup */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type ActionMenuGroupElement = React.ElementRef<typeof Menu.Group>;
|
||||
type MenuGroupProps = React.ComponentPropsWithoutRef<typeof Menu.Group>;
|
||||
|
||||
type ActionMenuGroupLabelingProps =
|
||||
| {
|
||||
/**
|
||||
* Adds a visual and accessible label to the group.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Adds an aria-label to the group.
|
||||
*/
|
||||
"aria-label"?: never;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Adds an aria-label to the group.
|
||||
*/
|
||||
"aria-label": string;
|
||||
/**
|
||||
* Adds a visual and accessible label to the group.
|
||||
*/
|
||||
label?: never;
|
||||
};
|
||||
|
||||
type ActionMenuGroupProps = Omit<MenuGroupProps, "asChild"> &
|
||||
ActionMenuGroupLabelingProps;
|
||||
|
||||
export const ActionMenuGroup = forwardRef<
|
||||
ActionMenuGroupElement,
|
||||
ActionMenuGroupProps
|
||||
>(({ children, className, label, ...rest }: ActionMenuGroupProps, ref) => {
|
||||
const labelId = useId();
|
||||
|
||||
return (
|
||||
<Menu.Group
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-action-menu__group", className)}
|
||||
asChild={false}
|
||||
aria-labelledby={label ? labelId : undefined}
|
||||
>
|
||||
{label && (
|
||||
<ActionMenu.Label id={labelId} aria-hidden>
|
||||
{label}
|
||||
</ActionMenu.Label>
|
||||
)}
|
||||
{children}
|
||||
</Menu.Group>
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility components */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type MarkerProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
placement: "left" | "right";
|
||||
};
|
||||
|
||||
const Marker = ({ children, className, placement }: MarkerProps) => {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cl(
|
||||
className,
|
||||
"navds-action-menu__marker",
|
||||
`navds-action-menu__marker--${placement}`,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ShortcutProps = {
|
||||
children: string;
|
||||
};
|
||||
|
||||
const Shortcut = ({ children }: ShortcutProps) => {
|
||||
/**
|
||||
* Assumes the user will input either a single keyboard key
|
||||
* or keys separated by "+"
|
||||
*/
|
||||
const parsed = children.split("+").filter((str) => str !== "");
|
||||
|
||||
return (
|
||||
<Marker placement="right">
|
||||
{parsed.map((char, index) => (
|
||||
<span key={char + index} className="navds-action-menu__shortcut">
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</Marker>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuItem */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type ActionMenuItemElement = React.ElementRef<typeof Menu.Item>;
|
||||
type MenuItemProps = React.ComponentPropsWithoutRef<typeof Menu.Item>;
|
||||
|
||||
interface ActionMenuItemProps extends Omit<MenuItemProps, "asChild"> {
|
||||
/**
|
||||
* Shows connected shortcut-keys for the item.
|
||||
* This is only a visual representation, you will have to implement the actual shortcut yourself.
|
||||
*/
|
||||
shortcut?: string;
|
||||
/**
|
||||
* Styles the item as a destructive action.
|
||||
*/
|
||||
variant?: "danger";
|
||||
/**
|
||||
* Adds an icon on the left side. The icon will always have aria-hidden.
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ActionMenuItem: OverridableComponent<
|
||||
ActionMenuItemProps,
|
||||
ActionMenuItemElement
|
||||
> = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
as: Component = "div",
|
||||
className,
|
||||
icon,
|
||||
shortcut,
|
||||
variant,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<Menu.Item
|
||||
{...rest}
|
||||
className={cl("navds-action-menu__item", className, {
|
||||
"navds-action-menu__item--danger": variant === "danger",
|
||||
"navds-action-menu__item--has-icon": icon,
|
||||
})}
|
||||
aria-keyshortcuts={shortcut ?? undefined}
|
||||
asChild
|
||||
>
|
||||
<Component ref={ref}>
|
||||
{children}
|
||||
{icon && (
|
||||
<Marker placement="left" className="navds-action-menu__marker-icon">
|
||||
{icon}
|
||||
</Marker>
|
||||
)}
|
||||
{shortcut && <Shortcut>{shortcut}</Shortcut>}
|
||||
</Component>
|
||||
</Menu.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuCheckboxItem */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type ActionMenuCheckboxItemElement = React.ElementRef<typeof Menu.CheckboxItem>;
|
||||
type MenuCheckboxItemProps = React.ComponentPropsWithoutRef<
|
||||
typeof Menu.CheckboxItem
|
||||
>;
|
||||
|
||||
interface ActionMenuCheckboxItemProps
|
||||
extends Omit<MenuCheckboxItemProps, "asChild"> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Shows connected shortcut-keys for the item.
|
||||
* This is only a visual representation, you will have to implement the actual shortcut yourself.
|
||||
*/
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
export const ActionMenuCheckboxItem = forwardRef<
|
||||
ActionMenuCheckboxItemElement,
|
||||
ActionMenuCheckboxItemProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
shortcut,
|
||||
onSelect,
|
||||
...rest
|
||||
}: ActionMenuCheckboxItemProps,
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<Menu.CheckboxItem
|
||||
ref={ref}
|
||||
{...rest}
|
||||
onSelect={composeEventHandlers(onSelect, (event) => {
|
||||
/**
|
||||
* Prevent default to avoid the menu from closing when clicking the checkbox
|
||||
*/
|
||||
event.preventDefault();
|
||||
})}
|
||||
asChild={false}
|
||||
className={cl(
|
||||
"navds-action-menu__item navds-action-menu__item--has-icon",
|
||||
className,
|
||||
)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
>
|
||||
{children}
|
||||
<Marker placement="left">
|
||||
<Menu.ItemIndicator className="navds-action-menu__indicator">
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="navds-action-menu__indicator-icon"
|
||||
aria-hidden
|
||||
>
|
||||
<g className="navds-action-menu__indicator-icon--unchecked">
|
||||
<rect
|
||||
width="24"
|
||||
height="24"
|
||||
rx="4"
|
||||
fill="var(--a-border-default)"
|
||||
/>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="22"
|
||||
height="22"
|
||||
rx="3"
|
||||
fill="var(--a-surface-default)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</g>
|
||||
<g className="navds-action-menu__indicator-icon--indeterminate">
|
||||
<rect
|
||||
width="24"
|
||||
height="24"
|
||||
rx="4"
|
||||
fill="var(--a-surface-action-selected)"
|
||||
/>
|
||||
<rect
|
||||
x="6"
|
||||
y="10"
|
||||
width="12"
|
||||
height="4"
|
||||
rx="1"
|
||||
fill="var(--a-surface-default)"
|
||||
/>
|
||||
</g>
|
||||
<g className="navds-action-menu__indicator-icon--checked">
|
||||
<rect
|
||||
width="24"
|
||||
height="24"
|
||||
rx="4"
|
||||
fill="var(--a-surface-action-selected)"
|
||||
/>
|
||||
<path
|
||||
d="M10.0352 13.4148L16.4752 7.40467C17.0792 6.83965 18.029 6.86933 18.5955 7.47478C19.162 8.08027 19.1296 9.03007 18.5245 9.59621L11.0211 16.5993C10.741 16.859 10.3756 17 10.0002 17C9.60651 17 9.22717 16.8462 8.93914 16.5611L6.43914 14.0611C5.85362 13.4756 5.85362 12.5254 6.43914 11.9399C7.02467 11.3544 7.97483 11.3544 8.56036 11.9399L10.0352 13.4148Z"
|
||||
fill="var(--a-surface-default)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</Menu.ItemIndicator>
|
||||
</Marker>
|
||||
|
||||
{shortcut && <Shortcut>{shortcut}</Shortcut>}
|
||||
</Menu.CheckboxItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuRadioGroup */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type ActionMenuRadioGroupElement = React.ElementRef<typeof Menu.RadioGroup>;
|
||||
type MenuRadioGroupProps = React.ComponentPropsWithoutRef<
|
||||
typeof Menu.RadioGroup
|
||||
>;
|
||||
type ActionMenuRadioGroupProps = ActionMenuGroupLabelingProps &
|
||||
Omit<MenuRadioGroupProps, "asChild"> & {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ActionMenuRadioGroup = forwardRef<
|
||||
ActionMenuRadioGroupElement,
|
||||
ActionMenuRadioGroupProps
|
||||
>(({ children, label, ...rest }: ActionMenuRadioGroupProps, ref) => {
|
||||
const labelId = useId();
|
||||
|
||||
return (
|
||||
<Menu.RadioGroup
|
||||
ref={ref}
|
||||
{...rest}
|
||||
asChild={false}
|
||||
aria-labelledby={label ? labelId : undefined}
|
||||
>
|
||||
{label && (
|
||||
<ActionMenu.Label id={labelId} aria-hidden>
|
||||
{label}
|
||||
</ActionMenu.Label>
|
||||
)}
|
||||
{children}
|
||||
</Menu.RadioGroup>
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuRadioItem */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type ActionMenuRadioItemElement = React.ElementRef<typeof Menu.RadioItem>;
|
||||
type MenuRadioItemProps = React.ComponentPropsWithoutRef<typeof Menu.RadioItem>;
|
||||
interface ActionMenuRadioItemProps extends Omit<MenuRadioItemProps, "asChild"> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ActionMenuRadioItem = forwardRef<
|
||||
ActionMenuRadioItemElement,
|
||||
ActionMenuRadioItemProps
|
||||
>(
|
||||
(
|
||||
{ children, className, onSelect, ...rest }: ActionMenuRadioItemProps,
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<Menu.RadioItem
|
||||
ref={ref}
|
||||
{...rest}
|
||||
onSelect={composeEventHandlers(onSelect, (event) => {
|
||||
/**
|
||||
* Prevent default to avoid the menu from closing when clicking the radio
|
||||
*/
|
||||
event.preventDefault();
|
||||
})}
|
||||
asChild={false}
|
||||
className={cl(
|
||||
"navds-action-menu__item navds-action-menu__item--has-icon",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<Marker placement="left">
|
||||
<Menu.ItemIndicator className="navds-action-menu__indicator">
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="navds-action-menu__indicator-icon"
|
||||
aria-hidden
|
||||
>
|
||||
<g className="navds-action-menu__indicator-icon--unchecked">
|
||||
<rect
|
||||
width="24"
|
||||
height="24"
|
||||
rx="12"
|
||||
fill="var(--a-border-default)"
|
||||
/>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="22"
|
||||
height="22"
|
||||
rx="11"
|
||||
strokeWidth="2"
|
||||
fill="var(--a-surface-default)"
|
||||
/>
|
||||
</g>
|
||||
<g className="navds-action-menu__indicator-icon--checked">
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="22"
|
||||
height="22"
|
||||
rx="11"
|
||||
fill="var(--a-surface-default)"
|
||||
/>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="22"
|
||||
height="22"
|
||||
rx="11"
|
||||
stroke="var(--a-surface-action-selected)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M20 12C20 16.4178 16.4178 20 12 20C7.58222 20 4 16.4178 4 12C4 7.58222 7.58222 4 12 4C16.4178 4 20 7.58222 20 12Z"
|
||||
fill="var(--a-surface-action-selected)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</Menu.ItemIndicator>
|
||||
</Marker>
|
||||
</Menu.RadioItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuDivider */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type ActionMenuDividerElement = React.ElementRef<typeof Menu.Divider>;
|
||||
type MenuDividerProps = React.ComponentPropsWithoutRef<typeof Menu.Divider>;
|
||||
type ActionMenuDividerProps = Omit<MenuDividerProps, "asChild">;
|
||||
|
||||
export const ActionMenuDivider = forwardRef<
|
||||
ActionMenuDividerElement,
|
||||
ActionMenuDividerProps
|
||||
>(({ className, ...rest }: ActionMenuDividerProps, ref) => {
|
||||
return (
|
||||
<Menu.Divider
|
||||
ref={ref}
|
||||
asChild={false}
|
||||
{...rest}
|
||||
className={cl("navds-action-menu__divider", className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuSub */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
interface ActionMenuSubProps {
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Whether the sub-menu is open or not. Only needed if you want to manually control state.
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Callback for when the sub-menu is opened or closed.
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ActionMenuSub = (props: ActionMenuSubProps) => {
|
||||
const { children, open: openProp, onOpenChange } = props;
|
||||
|
||||
const [open = false, setOpen] = useControllableState({
|
||||
value: openProp,
|
||||
defaultValue: false,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<Menu.Sub open={open} onOpenChange={setOpen}>
|
||||
{children}
|
||||
</Menu.Sub>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuSubTrigger */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type ActionMenuSubTriggerElement = React.ElementRef<typeof Menu.SubTrigger>;
|
||||
type MenuSubTriggerProps = React.ComponentPropsWithoutRef<
|
||||
typeof Menu.SubTrigger
|
||||
>;
|
||||
interface ActionMenuSubTriggerProps
|
||||
extends Omit<MenuSubTriggerProps, "asChild"> {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ActionMenuSubTrigger = forwardRef<
|
||||
ActionMenuSubTriggerElement,
|
||||
ActionMenuSubTriggerProps
|
||||
>(({ children, className, icon, ...rest }: ActionMenuSubTriggerProps, ref) => {
|
||||
return (
|
||||
<Menu.SubTrigger
|
||||
ref={ref}
|
||||
{...rest}
|
||||
asChild={false}
|
||||
className={cl(
|
||||
"navds-action-menu__item navds-action-menu__sub-trigger",
|
||||
className,
|
||||
{ "navds-action-menu__item--has-icon": icon },
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{icon && (
|
||||
<Marker placement="left" className="navds-action-menu__marker-icon">
|
||||
{icon}
|
||||
</Marker>
|
||||
)}
|
||||
<Marker placement="right" className="navds-action-menu__marker-icon">
|
||||
<ChevronRightIcon aria-hidden />
|
||||
</Marker>
|
||||
</Menu.SubTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ActionMenuSubContent */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type ActionMenuSubContentElement = React.ElementRef<typeof Menu.Content>;
|
||||
|
||||
interface ActionMenuSubContentProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ActionMenuSubContent = forwardRef<
|
||||
ActionMenuSubContentElement,
|
||||
ActionMenuSubContentProps
|
||||
>(({ children, className, style, ...rest }: ActionMenuSubContentProps, ref) => {
|
||||
const context = useActionMenuContext();
|
||||
|
||||
return (
|
||||
<Menu.Portal rootElement={context.rootElement}>
|
||||
<Menu.SubContent
|
||||
ref={ref}
|
||||
alignOffset={-4}
|
||||
sideOffset={1}
|
||||
collisionPadding={10}
|
||||
{...rest}
|
||||
className={cl(
|
||||
"navds-action-menu__content navds-action-menu__sub-content",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
...{
|
||||
"--ac-action-menu-content-transform-origin":
|
||||
"var(--ac-floating-transform-origin)",
|
||||
"--ac-action-menu-content-available-width":
|
||||
"var(--ac-floating-available-width)",
|
||||
"--ac-action-menu-content-available-height":
|
||||
"var(--ac-floating-available-height)",
|
||||
"--ac-action-menu-trigger-width": "var(--ac-floating-anchor-width)",
|
||||
"--ac-action-menu-trigger-height":
|
||||
"var(--ac-floating-anchor-height)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="navds-action-menu__content-inner">{children}</div>
|
||||
</Menu.SubContent>
|
||||
</Menu.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
ActionMenu.Trigger = ActionMenuTrigger;
|
||||
ActionMenu.Content = ActionMenuContent;
|
||||
ActionMenu.Group = ActionMenuGroup;
|
||||
ActionMenu.Label = ActionMenuLabel;
|
||||
ActionMenu.Item = ActionMenuItem;
|
||||
ActionMenu.CheckboxItem = ActionMenuCheckboxItem;
|
||||
ActionMenu.RadioGroup = ActionMenuRadioGroup;
|
||||
ActionMenu.RadioItem = ActionMenuRadioItem;
|
||||
ActionMenu.Divider = ActionMenuDivider;
|
||||
ActionMenu.Sub = ActionMenuSub;
|
||||
ActionMenu.SubTrigger = ActionMenuSubTrigger;
|
||||
ActionMenu.SubContent = ActionMenuSubContent;
|
||||
|
||||
export type {
|
||||
ActionMenuItemProps,
|
||||
ActionMenuCheckboxItemProps,
|
||||
ActionMenuContentProps,
|
||||
ActionMenuDividerProps,
|
||||
ActionMenuGroupProps,
|
||||
ActionMenuLabelProps,
|
||||
ActionMenuProps,
|
||||
ActionMenuRadioGroupProps,
|
||||
ActionMenuRadioItemProps,
|
||||
ActionMenuSubContentProps,
|
||||
ActionMenuSubProps,
|
||||
ActionMenuSubTriggerProps,
|
||||
ActionMenuTriggerProps,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
export {
|
||||
ActionMenu,
|
||||
ActionMenuCheckboxItem,
|
||||
ActionMenuContent,
|
||||
ActionMenuGroup,
|
||||
ActionMenuItem,
|
||||
ActionMenuLabel,
|
||||
ActionMenuRadioGroup,
|
||||
ActionMenuRadioItem,
|
||||
ActionMenuDivider,
|
||||
ActionMenuSub,
|
||||
ActionMenuSubContent,
|
||||
ActionMenuSubTrigger,
|
||||
ActionMenuTrigger,
|
||||
type ActionMenuItemProps,
|
||||
type ActionMenuCheckboxItemProps,
|
||||
type ActionMenuContentProps,
|
||||
type ActionMenuGroupProps,
|
||||
type ActionMenuLabelProps,
|
||||
type ActionMenuProps,
|
||||
type ActionMenuRadioGroupProps,
|
||||
type ActionMenuRadioItemProps,
|
||||
type ActionMenuDividerProps,
|
||||
type ActionMenuSubContentProps,
|
||||
type ActionMenuSubProps,
|
||||
type ActionMenuSubTriggerProps,
|
||||
type ActionMenuTriggerProps,
|
||||
} from "./ActionMenu";
|
||||
@@ -22,7 +22,7 @@ export const Styled = () => (
|
||||
<Menu.Item className="item" onSelect={() => window.alert("redo")}>
|
||||
Redo
|
||||
</Menu.Item>
|
||||
<Menu.Separator className="separator" />
|
||||
<Menu.Divider className="separator" />
|
||||
<Menu.Item className="item" disabled onSelect={() => window.alert("cut")}>
|
||||
Cut
|
||||
</Menu.Item>
|
||||
@@ -103,7 +103,7 @@ export const Submenus = () => {
|
||||
</Menu.Item>
|
||||
</Submenu>
|
||||
|
||||
<Menu.Separator className="separator" />
|
||||
<Menu.Divider className="separator" />
|
||||
<Menu.Item className="item" disabled onSelect={() => window.alert("cut")}>
|
||||
Cut
|
||||
</Menu.Item>
|
||||
@@ -179,7 +179,7 @@ export const WithLabels = () => (
|
||||
</Menu.Item>
|
||||
))}
|
||||
{index < foodGroups.length - 1 && (
|
||||
<Menu.Separator className="separator" />
|
||||
<Menu.Divider className="separator" />
|
||||
)}
|
||||
</Menu.Group>
|
||||
))}
|
||||
@@ -217,7 +217,7 @@ export const CheckboxItems = () => {
|
||||
Select all
|
||||
{selection.length === options.length ? <TickIcon /> : <div>—</div>}
|
||||
</Menu.CheckboxItem>
|
||||
<Menu.Separator className="separator" />
|
||||
<Menu.Divider className="separator" />
|
||||
{options.map((option) => (
|
||||
<Menu.CheckboxItem
|
||||
key={option}
|
||||
@@ -256,7 +256,7 @@ export const RadioItems = () => {
|
||||
<Menu.Item className="item" onSelect={() => window.alert("smaller")}>
|
||||
Smaller
|
||||
</Menu.Item>
|
||||
<Menu.Separator className="separator" />
|
||||
<Menu.Divider className="separator" />
|
||||
<Menu.RadioGroup value={file} onValueChange={setFile}>
|
||||
{files.map((_file) => (
|
||||
<Menu.RadioItem key={_file} className="item" value={_file}>
|
||||
@@ -426,7 +426,7 @@ export const MenuWithOpenButton = () => {
|
||||
<Menu.Item className="item" onSelect={() => window.alert("redo")}>
|
||||
Redo
|
||||
</Menu.Item>
|
||||
<Menu.Separator className="separator" />
|
||||
<Menu.Divider className="separator" />
|
||||
<Menu.Item
|
||||
className="item"
|
||||
disabled
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Portal } from "../../portal";
|
||||
import { composeEventHandlers } from "../../util/composeEventHandlers";
|
||||
@@ -24,17 +18,10 @@ import {
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Constants */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
const SELECTION_KEYS = ["Enter", " "];
|
||||
const SUB_OPEN_KEYS = [...SELECTION_KEYS, "ArrowRight"];
|
||||
const SUB_CLOSE_KEYS = ["ArrowLeft"];
|
||||
const FIRST_KEYS = ["ArrowDown", "PageUp", "Home"];
|
||||
const LAST_KEYS = ["ArrowUp", "PageDown", "End"];
|
||||
const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS];
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type Polygon = Point[];
|
||||
type SubMenuSide = "left" | "right";
|
||||
type GraceIntent = { area: Polygon; side: SubMenuSide };
|
||||
type CheckedState = boolean | "indeterminate";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -56,7 +43,7 @@ interface MenuComponent extends React.FC<MenuProps> {
|
||||
CheckboxItem: typeof MenuCheckboxItem;
|
||||
RadioGroup: typeof MenuRadioGroup;
|
||||
RadioItem: typeof MenuRadioItem;
|
||||
Separator: typeof MenuSeparator;
|
||||
Divider: typeof MenuDivider;
|
||||
Sub: typeof MenuSub;
|
||||
SubTrigger: typeof MenuSubTrigger;
|
||||
SubContent: typeof MenuSubContent;
|
||||
@@ -68,7 +55,12 @@ const [
|
||||
useMenuDescendantsContext,
|
||||
useMenuDescendants,
|
||||
useMenuDescendant,
|
||||
] = createDescendantContext<SlottedDivElementRef>();
|
||||
] = createDescendantContext<
|
||||
SlottedDivElementRef,
|
||||
{
|
||||
closeMenu: () => void;
|
||||
}
|
||||
>();
|
||||
|
||||
type MenuContentElementRef = React.ElementRef<typeof Floating.Content>;
|
||||
|
||||
@@ -180,22 +172,8 @@ const MenuAnchor = forwardRef<MenuAnchorElement, MenuAnchorProps>(
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Menu Content */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
type MenuContentContextValue = {
|
||||
onItemEnter: (event: React.PointerEvent) => void;
|
||||
onItemLeave: (event: React.PointerEvent) => void;
|
||||
onPointerLeaveTrigger: (event: React.PointerEvent) => void;
|
||||
pointerGraceTimerRef: React.MutableRefObject<number>;
|
||||
onPointerGraceIntentChange: (intent: GraceIntent | null) => void;
|
||||
};
|
||||
|
||||
const [MenuContentProvider, useMenuContentContext] =
|
||||
createContext<MenuContentContextValue>({
|
||||
providerName: "MenuContentProvider",
|
||||
hookName: "useMenuContentContext",
|
||||
});
|
||||
|
||||
type MenuContentElement = MenuContentInternalElement;
|
||||
interface MenuContentProps extends MenuContentInternalTypeProps {}
|
||||
type MenuContentProps = MenuContentInternalTypeProps;
|
||||
|
||||
const MenuContent = React.forwardRef<
|
||||
MenuContentInternalElement,
|
||||
@@ -286,6 +264,7 @@ interface MenuContentInternalProps
|
||||
onPointerDownOutside?: DismissableLayerProps["onPointerDownOutside"];
|
||||
onFocusOutside?: DismissableLayerProps["onFocusOutside"];
|
||||
onInteractOutside?: DismissableLayerProps["onInteractOutside"];
|
||||
safeZone?: DismissableLayerProps["safeZone"];
|
||||
}
|
||||
|
||||
const MenuContentInternal = forwardRef<
|
||||
@@ -303,6 +282,7 @@ const MenuContentInternal = forwardRef<
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
onDismiss,
|
||||
safeZone,
|
||||
...rest
|
||||
}: MenuContentInternalProps,
|
||||
forwardedRef,
|
||||
@@ -318,149 +298,80 @@ const MenuContentInternal = forwardRef<
|
||||
contentRef,
|
||||
context.onContentChange,
|
||||
);
|
||||
const pointerGraceTimerRef = React.useRef(0);
|
||||
const pointerGraceIntentRef = React.useRef<GraceIntent | null>(null);
|
||||
const pointerDirRef = React.useRef<SubMenuSide>("right");
|
||||
const lastPointerXRef = React.useRef(0);
|
||||
|
||||
const isPointerMovingToSubmenu = React.useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
const isMovingTowards =
|
||||
pointerDirRef.current === pointerGraceIntentRef.current?.side;
|
||||
return (
|
||||
isMovingTowards &&
|
||||
isPointerInGraceArea(event, pointerGraceIntentRef.current?.area)
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuContentProvider
|
||||
onItemEnter={React.useCallback(
|
||||
(event) => {
|
||||
if (isPointerMovingToSubmenu(event)) event.preventDefault();
|
||||
},
|
||||
[isPointerMovingToSubmenu],
|
||||
)}
|
||||
onItemLeave={React.useCallback(
|
||||
(event) => {
|
||||
if (isPointerMovingToSubmenu(event)) return;
|
||||
|
||||
/**
|
||||
* Resets focus from current active item to content area
|
||||
* This is to prevent focus from being stuck on an item when we move pointer outside the menu or onto a disabled item
|
||||
*/
|
||||
contentRef.current?.focus();
|
||||
},
|
||||
[isPointerMovingToSubmenu],
|
||||
)}
|
||||
onPointerLeaveTrigger={React.useCallback(
|
||||
(event) => {
|
||||
if (isPointerMovingToSubmenu(event)) event.preventDefault();
|
||||
},
|
||||
[isPointerMovingToSubmenu],
|
||||
)}
|
||||
pointerGraceTimerRef={pointerGraceTimerRef}
|
||||
onPointerGraceIntentChange={React.useCallback((intent) => {
|
||||
pointerGraceIntentRef.current = intent;
|
||||
}, [])}
|
||||
<FocusScope
|
||||
onMountHandler={composeEventHandlers(onOpenAutoFocus, (event) => {
|
||||
// when opening, explicitly focus the content area only and leave
|
||||
// `onEntryFocus` in control of focusing first item
|
||||
event.preventDefault();
|
||||
contentRef.current?.focus({ preventScroll: true });
|
||||
})}
|
||||
onUnmountHandler={onCloseAutoFocus}
|
||||
>
|
||||
<FocusScope
|
||||
onMountHandler={composeEventHandlers(onOpenAutoFocus, (event) => {
|
||||
// when opening, explicitly focus the content area only and leave
|
||||
// `onEntryFocus` in control of focusing first item
|
||||
event.preventDefault();
|
||||
contentRef.current?.focus({ preventScroll: true });
|
||||
})}
|
||||
onUnmountHandler={onCloseAutoFocus}
|
||||
<DismissableLayer
|
||||
asChild
|
||||
disableOutsidePointerEvents={disableOutsidePointerEvents}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
onDismiss={onDismiss}
|
||||
safeZone={safeZone}
|
||||
>
|
||||
<DismissableLayer
|
||||
<RovingFocus
|
||||
asChild
|
||||
disableOutsidePointerEvents={disableOutsidePointerEvents}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
onDismiss={onDismiss}
|
||||
descendants={descendants}
|
||||
onEntryFocus={composeEventHandlers(onEntryFocus, (event) => {
|
||||
// only focus first item when using keyboard
|
||||
if (!rootContext.isUsingKeyboardRef.current)
|
||||
event.preventDefault();
|
||||
})}
|
||||
>
|
||||
<RovingFocus
|
||||
asChild
|
||||
descendants={descendants}
|
||||
onEntryFocus={composeEventHandlers(onEntryFocus, (event) => {
|
||||
// only focus first item when using keyboard
|
||||
if (!rootContext.isUsingKeyboardRef.current)
|
||||
event.preventDefault();
|
||||
<Floating.Content
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
data-state={getOpenState(context.open)}
|
||||
data-aksel-menu-content=""
|
||||
dir="ltr"
|
||||
{...rest}
|
||||
ref={composedRefs}
|
||||
style={{ outline: "none", ...rest.style }}
|
||||
onKeyDown={composeEventHandlers(rest.onKeyDown, (event) => {
|
||||
// submenu key events bubble through portals. We only care about keys in this menu.
|
||||
const target = event.target as HTMLElement;
|
||||
const isKeyDownInside =
|
||||
target.closest("[data-aksel-menu-content]") ===
|
||||
event.currentTarget;
|
||||
if (isKeyDownInside) {
|
||||
// menus should not be navigated using tab key so we prevent it
|
||||
if (event.key === "Tab") event.preventDefault();
|
||||
}
|
||||
|
||||
// focus first/last item based on key pressed
|
||||
const content = contentRef.current;
|
||||
if (event.target !== content) return;
|
||||
if (!FIRST_LAST_KEYS.includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
|
||||
if (LAST_KEYS.includes(event.key)) {
|
||||
descendants.lastEnabled()?.node?.focus();
|
||||
return;
|
||||
}
|
||||
descendants.firstEnabled()?.node?.focus();
|
||||
})}
|
||||
>
|
||||
<Floating.Content
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
data-state={getOpenState(context.open)}
|
||||
data-aksel-menu-content=""
|
||||
dir="ltr"
|
||||
{...rest}
|
||||
ref={composedRefs}
|
||||
style={{ outline: "none", ...rest.style }}
|
||||
onKeyDown={composeEventHandlers(rest.onKeyDown, (event) => {
|
||||
// submenu key events bubble through portals. We only care about keys in this menu.
|
||||
const target = event.target as HTMLElement;
|
||||
const isKeyDownInside =
|
||||
target.closest("[data-aksel-menu-content]") ===
|
||||
event.currentTarget;
|
||||
if (isKeyDownInside) {
|
||||
// menus should not be navigated using tab key so we prevent it
|
||||
if (event.key === "Tab") event.preventDefault();
|
||||
}
|
||||
|
||||
// focus first/last item based on key pressed
|
||||
const content = contentRef.current;
|
||||
if (event.target !== content) return;
|
||||
if (!FIRST_LAST_KEYS.includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
|
||||
if (LAST_KEYS.includes(event.key)) {
|
||||
descendants.lastEnabled()?.node?.focus();
|
||||
return;
|
||||
}
|
||||
descendants.firstEnabled()?.node?.focus();
|
||||
})}
|
||||
onPointerMove={composeEventHandlers(
|
||||
rest.onPointerMove,
|
||||
whenMouse((event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const pointerXHasChanged =
|
||||
lastPointerXRef.current !== event.clientX;
|
||||
|
||||
// We don't use `event.movementX` for this check because Safari will
|
||||
// always return `0` on a pointer event.
|
||||
if (
|
||||
event.currentTarget.contains(target) &&
|
||||
pointerXHasChanged
|
||||
) {
|
||||
const newDir =
|
||||
event.clientX > lastPointerXRef.current
|
||||
? "right"
|
||||
: "left";
|
||||
pointerDirRef.current = newDir;
|
||||
lastPointerXRef.current = event.clientX;
|
||||
}
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</RovingFocus>
|
||||
</DismissableLayer>
|
||||
</FocusScope>
|
||||
</MenuContentProvider>
|
||||
/>
|
||||
</RovingFocus>
|
||||
</DismissableLayer>
|
||||
</FocusScope>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface MenuContentInternalTypeProps
|
||||
extends Omit<
|
||||
MenuContentInternalProps,
|
||||
keyof MenuContentInternalPrivateProps
|
||||
> {}
|
||||
type MenuContentInternalTypeProps = Omit<
|
||||
MenuContentInternalProps,
|
||||
keyof MenuContentInternalPrivateProps
|
||||
>;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Menu item */
|
||||
@@ -482,6 +393,7 @@ const MenuItem = forwardRef<MenuItemElement, MenuItemProps>(
|
||||
onPointerUp,
|
||||
onPointerDown,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
...rest
|
||||
}: MenuItemProps,
|
||||
forwardedRef,
|
||||
@@ -510,6 +422,28 @@ const MenuItem = forwardRef<MenuItemElement, MenuItemProps>(
|
||||
} else {
|
||||
rootContext.onClose();
|
||||
}
|
||||
} else if (!disabled && menuItem) {
|
||||
rootContext.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKey = (
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
key: "Enter" | " ",
|
||||
) => {
|
||||
if (disabled || event.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === event.key) {
|
||||
event.currentTarget.click();
|
||||
/**
|
||||
* We prevent default browser behaviour for selection keys as they should only trigger
|
||||
* selection.
|
||||
* - Prevents space from scrolling the page.
|
||||
* - If keydown causes focus to move, prevents keydown from firing on the new target.
|
||||
*/
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -519,7 +453,13 @@ const MenuItem = forwardRef<MenuItemElement, MenuItemProps>(
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
ref={composedRefs}
|
||||
disabled={disabled}
|
||||
onClick={composeEventHandlers(onClick, handleSelect)}
|
||||
onClick={composeEventHandlers(onClick, handleSelect, {
|
||||
/**
|
||||
* Nextjs prevents default on click when using Link component, so we have to force click-event
|
||||
* https://github.com/vercel/next.js/blob/77dcd4c66a35d0e8ef639bda4d05873bd3c0f52d/packages/next/src/client/link.tsx#L211
|
||||
*/
|
||||
checkForDefaultPrevented: false,
|
||||
})}
|
||||
onPointerDown={composeEventHandlers(
|
||||
onPointerDown,
|
||||
() => {
|
||||
@@ -533,21 +473,12 @@ const MenuItem = forwardRef<MenuItemElement, MenuItemProps>(
|
||||
// prevent Firefox from getting stuck in text selection mode when the menu closes.
|
||||
if (!isPointerDownRef.current) event.currentTarget?.click();
|
||||
})}
|
||||
onKeyDown={composeEventHandlers(onKeyDown, (event) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (SELECTION_KEYS.includes(event.key)) {
|
||||
event.currentTarget.click();
|
||||
/**
|
||||
* We prevent default browser behaviour for selection keys as they should only trigger
|
||||
* selection.
|
||||
* - Prevents space from scrolling the page.
|
||||
* - If keydown causes focus to move, prevents keydown from firing on the new target.
|
||||
*/
|
||||
event.preventDefault();
|
||||
}
|
||||
})}
|
||||
onKeyDown={composeEventHandlers(onKeyDown, (event) =>
|
||||
handleKey(event, "Enter"),
|
||||
)}
|
||||
onKeyUp={composeEventHandlers(onKeyUp, (event) =>
|
||||
handleKey(event, " "),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -573,9 +504,16 @@ const MenuItemInternal = forwardRef<
|
||||
}: MenuItemInternalProps,
|
||||
forwardedRef,
|
||||
) => {
|
||||
const { register } = useMenuDescendant({ disabled });
|
||||
const context = useMenuContext();
|
||||
const { register } = useMenuDescendant({
|
||||
disabled,
|
||||
closeMenu: () => {
|
||||
rest["data-submenu-trigger"] &&
|
||||
context.open &&
|
||||
context.onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const contentContext = useMenuContentContext();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const composedRefs = useMergeRefs(forwardedRef, ref, register);
|
||||
|
||||
@@ -601,18 +539,15 @@ const MenuItemInternal = forwardRef<
|
||||
* In the edgecase the focus is still stuck on a previous item, we make sure to reset it
|
||||
* even when the disabled item can't be focused itself to reset it.
|
||||
*/
|
||||
contentContext.onItemLeave(event);
|
||||
context.content?.focus();
|
||||
} else {
|
||||
contentContext.onItemEnter(event);
|
||||
if (!event.defaultPrevented) {
|
||||
event.currentTarget.focus();
|
||||
}
|
||||
event.currentTarget.focus();
|
||||
}
|
||||
}),
|
||||
)}
|
||||
onPointerLeave={composeEventHandlers(
|
||||
onPointerLeave,
|
||||
whenMouse(contentContext.onItemLeave),
|
||||
whenMouse(() => context.content?.focus()),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -622,7 +557,7 @@ const MenuItemInternal = forwardRef<
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Menu Group */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
interface MenuGroupProps extends SlottedDivProps {}
|
||||
type MenuGroupProps = SlottedDivProps;
|
||||
|
||||
const MenuGroup = forwardRef<SlottedDivElementRef, MenuGroupProps>(
|
||||
(props: MenuGroupProps, ref) => {
|
||||
@@ -696,7 +631,7 @@ const [MenuItemIndicatorProvider, useMenuItemIndicatorContext] = createContext<{
|
||||
hookName: "useMenuItemIndicatorContext",
|
||||
});
|
||||
|
||||
interface MenuItemIndicatorProps extends SlottedDivProps {}
|
||||
type MenuItemIndicatorProps = SlottedDivProps;
|
||||
|
||||
const MenuItemIndicator = forwardRef<
|
||||
SlottedDivElementRef,
|
||||
@@ -786,12 +721,12 @@ const MenuCheckboxItem = forwardRef<MenuItemElement, MenuCheckboxItemProps>(
|
||||
);
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Menu Separator */
|
||||
/* Menu Divider */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
interface MenuSeparatorProps extends SlottedDivProps {}
|
||||
type MenuDividerProps = SlottedDivProps;
|
||||
|
||||
const MenuSeparator = forwardRef<SlottedDivElementRef, MenuSeparatorProps>(
|
||||
(props: MenuSeparatorProps, ref) => {
|
||||
const MenuDivider = forwardRef<SlottedDivElementRef, MenuDividerProps>(
|
||||
(props: MenuDividerProps, ref) => {
|
||||
return (
|
||||
<SlottedDivElement
|
||||
role="separator"
|
||||
@@ -833,6 +768,8 @@ const MenuSub: React.FC<MenuSubProps> = ({
|
||||
}: MenuSubProps) => {
|
||||
const parentMenuContext = useMenuContext();
|
||||
|
||||
const { values } = useMenuDescendantsContext();
|
||||
|
||||
const [trigger, setTrigger] = useState<MenuItemElement | null>(null);
|
||||
const [content, setContent] = useState<MenuContentInternalElement | null>(
|
||||
null,
|
||||
@@ -851,7 +788,17 @@ const MenuSub: React.FC<MenuSubProps> = ({
|
||||
<Floating>
|
||||
<MenuProvider
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
onOpenChange={(_open) => {
|
||||
handleOpenChange(_open);
|
||||
if (_open) {
|
||||
/* Makes sure to close all adjacent submenus if they are open */
|
||||
values().forEach((descendant) => {
|
||||
if (descendant.node !== trigger) {
|
||||
descendant.closeMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
content={content}
|
||||
onContentChange={setContent}
|
||||
>
|
||||
@@ -871,34 +818,31 @@ const MenuSub: React.FC<MenuSubProps> = ({
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Menu SubMenu Trigger */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
interface MenuSubTriggerProps extends MenuItemInternalProps {}
|
||||
type MenuSubTriggerProps = MenuItemInternalProps;
|
||||
|
||||
const MenuSubTrigger = forwardRef<MenuItemElement, MenuSubTriggerProps>(
|
||||
(props: MenuSubTriggerProps, forwardedRef) => {
|
||||
const context = useMenuContext();
|
||||
const subContext = useMenuSubContext();
|
||||
const contentContext = useMenuContentContext();
|
||||
const openTimerRef = useRef<number | null>(null);
|
||||
const { pointerGraceTimerRef, onPointerGraceIntentChange } = contentContext;
|
||||
|
||||
const composedRefs = useMergeRefs(forwardedRef, subContext.onTriggerChange);
|
||||
|
||||
const clearOpenTimer = useCallback(() => {
|
||||
if (openTimerRef.current) {
|
||||
window.clearTimeout(openTimerRef.current);
|
||||
const handleKey = (
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
keys: string[],
|
||||
) => {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
openTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => clearOpenTimer, [clearOpenTimer]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const pointerGraceTimer = pointerGraceTimerRef.current;
|
||||
return () => {
|
||||
window.clearTimeout(pointerGraceTimer);
|
||||
onPointerGraceIntentChange(null);
|
||||
};
|
||||
}, [pointerGraceTimerRef, onPointerGraceIntentChange]);
|
||||
if (keys.includes(event.key)) {
|
||||
context.onOpenChange(true);
|
||||
// The trigger may hold focus if opened via pointer interaction
|
||||
// so we ensure content is given focus again when switching to keyboard.
|
||||
context.content?.focus();
|
||||
// prevent window from scrolling
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuAnchor asChild>
|
||||
@@ -910,85 +854,25 @@ const MenuSubTrigger = forwardRef<MenuItemElement, MenuSubTriggerProps>(
|
||||
data-state={getOpenState(context.open)}
|
||||
{...props}
|
||||
ref={composedRefs}
|
||||
/**
|
||||
* onClick is added to solve edgecase where the user clicks the trigger,
|
||||
* but the focus is outside browser-window or viewport at first.
|
||||
*/
|
||||
data-submenu-trigger
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event);
|
||||
if (props.disabled || event.defaultPrevented) return;
|
||||
|
||||
event.currentTarget.focus();
|
||||
if (!context.open) context.onOpenChange(true);
|
||||
}}
|
||||
onPointerMove={composeEventHandlers(
|
||||
props.onPointerMove,
|
||||
whenMouse((event) => {
|
||||
if (event.defaultPrevented) return;
|
||||
if (!props.disabled && !context.open && !openTimerRef.current) {
|
||||
contentContext.onPointerGraceIntentChange(null);
|
||||
openTimerRef.current = window.setTimeout(() => {
|
||||
context.onOpenChange(true);
|
||||
clearOpenTimer();
|
||||
}, 100);
|
||||
}
|
||||
}),
|
||||
)}
|
||||
onPointerLeave={composeEventHandlers(
|
||||
props.onPointerLeave,
|
||||
whenMouse((event) => {
|
||||
clearOpenTimer();
|
||||
|
||||
const contentRect = context.content?.getBoundingClientRect();
|
||||
if (contentRect) {
|
||||
const side = context.content?.dataset.side as SubMenuSide;
|
||||
const rightSide = side === "right";
|
||||
const bleed = rightSide ? -5 : +5;
|
||||
const contentNearEdge =
|
||||
contentRect[rightSide ? "left" : "right"];
|
||||
const contentFarEdge =
|
||||
contentRect[rightSide ? "right" : "left"];
|
||||
|
||||
contentContext.onPointerGraceIntentChange({
|
||||
area: [
|
||||
// Apply a bleed on clientX to ensure that our exit point is
|
||||
// consistently within polygon bounds
|
||||
{ x: event.clientX + bleed, y: event.clientY },
|
||||
{ x: contentNearEdge, y: contentRect.top },
|
||||
{ x: contentFarEdge, y: contentRect.top },
|
||||
{ x: contentFarEdge, y: contentRect.bottom },
|
||||
{ x: contentNearEdge, y: contentRect.bottom },
|
||||
],
|
||||
side,
|
||||
});
|
||||
|
||||
window.clearTimeout(pointerGraceTimerRef.current);
|
||||
pointerGraceTimerRef.current = window.setTimeout(
|
||||
() => contentContext.onPointerGraceIntentChange(null),
|
||||
300,
|
||||
);
|
||||
} else {
|
||||
contentContext.onPointerLeaveTrigger(event);
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
// There's 100ms where the user may leave an item before the submenu was opened.
|
||||
contentContext.onPointerGraceIntentChange(null);
|
||||
}
|
||||
}),
|
||||
)}
|
||||
onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
|
||||
if (props.disabled) {
|
||||
if (props.disabled || event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
if (SUB_OPEN_KEYS.includes(event.key)) {
|
||||
context.onOpenChange(true);
|
||||
// The trigger may hold focus if opened via pointer interaction
|
||||
// so we ensure content is given focus again when switching to keyboard.
|
||||
context.content?.focus();
|
||||
// prevent window from scrolling
|
||||
event.preventDefault();
|
||||
}
|
||||
})}
|
||||
props.onClick?.(event);
|
||||
/*
|
||||
* Solves edgecase where the user clicks the trigger,
|
||||
* but the focus is outside browser-window or viewport at first.
|
||||
*/
|
||||
event.currentTarget.focus();
|
||||
context.onOpenChange(!context.open);
|
||||
}}
|
||||
onKeyDown={composeEventHandlers(props.onKeyDown, (event) =>
|
||||
handleKey(event, ["Enter", "ArrowRight"]),
|
||||
)}
|
||||
onKeyUp={composeEventHandlers(props.onKeyUp, (event) =>
|
||||
handleKey(event, [" "]),
|
||||
)}
|
||||
/>
|
||||
</MenuAnchor>
|
||||
);
|
||||
@@ -998,15 +882,14 @@ const MenuSubTrigger = forwardRef<MenuItemElement, MenuSubTriggerProps>(
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Menu SubMenu Content */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
interface MenuSubContentProps
|
||||
extends Omit<
|
||||
MenuContentInternalProps,
|
||||
| keyof MenuContentInternalPrivateProps
|
||||
| "onCloseAutoFocus"
|
||||
| "onEntryFocus"
|
||||
| "side"
|
||||
| "align"
|
||||
> {}
|
||||
type MenuSubContentProps = Omit<
|
||||
MenuContentInternalProps,
|
||||
| keyof MenuContentInternalPrivateProps
|
||||
| "onCloseAutoFocus"
|
||||
| "onEntryFocus"
|
||||
| "side"
|
||||
| "align"
|
||||
>;
|
||||
|
||||
const MenuSubContent = forwardRef<
|
||||
MenuContentInternalElement,
|
||||
@@ -1037,14 +920,8 @@ const MenuSubContent = forwardRef<
|
||||
}
|
||||
event.preventDefault();
|
||||
}}
|
||||
// The menu might close because of focusing another menu item in the parent menu. We
|
||||
// don't want it to refocus the trigger in that case so we handle trigger focus ourselves.
|
||||
/* Since we manually focus Subtrigger, we prevent use of auto-focus */
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => {
|
||||
// We prevent closing when the trigger is focused to avoid triggering a re-open animation
|
||||
// on pointer interaction.
|
||||
if (event.target !== subContext.trigger) context.onOpenChange(false);
|
||||
})}
|
||||
onEscapeKeyDown={composeEventHandlers(
|
||||
props.onEscapeKeyDown,
|
||||
(event) => {
|
||||
@@ -1058,7 +935,7 @@ const MenuSubContent = forwardRef<
|
||||
const isKeyDownInside = event.currentTarget.contains(
|
||||
event.target as HTMLElement,
|
||||
);
|
||||
let isCloseKey = SUB_CLOSE_KEYS.includes(event.key);
|
||||
let isCloseKey = event.key === "ArrowLeft";
|
||||
|
||||
/* When submenu opens to the left, we allow closing it with ArrowRight */
|
||||
if (context.content?.dataset.side === "left") {
|
||||
@@ -1097,32 +974,6 @@ function getCheckedState(checked: CheckedState) {
|
||||
: "unchecked";
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a point is inside of a polygon.
|
||||
*/
|
||||
function isPointInPolygon(point: Point, polygon: Polygon) {
|
||||
const { x, y } = point;
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x;
|
||||
const yi = polygon[i].y;
|
||||
const xj = polygon[j].x;
|
||||
const yj = polygon[j].y;
|
||||
|
||||
// prettier-ignore
|
||||
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
|
||||
function isPointerInGraceArea(event: React.PointerEvent, area?: Polygon) {
|
||||
if (!area) return false;
|
||||
const cursorPos = { x: event.clientX, y: event.clientY };
|
||||
return isPointInPolygon(cursorPos, area);
|
||||
}
|
||||
|
||||
function whenMouse<E>(
|
||||
handler: React.PointerEventHandler<E>,
|
||||
): React.PointerEventHandler<E> {
|
||||
@@ -1139,7 +990,7 @@ Menu.Item = MenuItem;
|
||||
Menu.CheckboxItem = MenuCheckboxItem;
|
||||
Menu.RadioGroup = MenuRadioGroup;
|
||||
Menu.RadioItem = MenuRadioItem;
|
||||
Menu.Separator = MenuSeparator;
|
||||
Menu.Divider = MenuDivider;
|
||||
Menu.Sub = MenuSub;
|
||||
Menu.SubTrigger = MenuSubTrigger;
|
||||
Menu.SubContent = MenuSubContent;
|
||||
@@ -1150,19 +1001,20 @@ export {
|
||||
MenuAnchor,
|
||||
MenuCheckboxItem,
|
||||
MenuContent,
|
||||
MenuDivider,
|
||||
MenuGroup,
|
||||
MenuItem,
|
||||
MenuItemIndicator,
|
||||
MenuPortal,
|
||||
MenuRadioGroup,
|
||||
MenuRadioItem,
|
||||
MenuSeparator,
|
||||
MenuSub,
|
||||
MenuSubContent,
|
||||
MenuSubTrigger,
|
||||
type MenuAnchorProps,
|
||||
type MenuCheckboxItemProps,
|
||||
type MenuContentProps,
|
||||
type MenuDividerProps,
|
||||
type MenuGroupProps,
|
||||
type MenuItemElement,
|
||||
type MenuItemIndicatorProps,
|
||||
@@ -1170,7 +1022,6 @@ export {
|
||||
type MenuProps,
|
||||
type MenuRadioGroupProps,
|
||||
type MenuRadioItemProps,
|
||||
type MenuSeparatorProps,
|
||||
type MenuSubContentProps,
|
||||
type MenuSubProps,
|
||||
type MenuSubTriggerProps,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DescendantsManager } from "../../../util/hooks/descendants/descendant";
|
||||
interface RovingFocusProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "tabIndex"> {
|
||||
asChild?: boolean;
|
||||
descendants: DescendantsManager<HTMLDivElement, object>;
|
||||
descendants: DescendantsManager<HTMLDivElement, any>;
|
||||
onEntryFocus?: (event: Event) => void;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ const RovingFocus = forwardRef<HTMLDivElement, RovingFocusProps>(
|
||||
onMouseDown,
|
||||
onFocus,
|
||||
...rest
|
||||
}: RovingFocusProps,
|
||||
ref,
|
||||
},
|
||||
ref: React.Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const _ref = React.useRef<HTMLDivElement>(null);
|
||||
const composedRefs = useMergeRefs(ref, _ref);
|
||||
@@ -55,19 +55,19 @@ const RovingFocus = forwardRef<HTMLDivElement, RovingFocusProps>(
|
||||
|
||||
const nextItem = () => {
|
||||
const next = descendants.nextEnabled(idx, loop);
|
||||
next && next.node?.focus();
|
||||
next?.node?.focus();
|
||||
};
|
||||
const prevItem = () => {
|
||||
const prev = descendants.prevEnabled(idx, loop);
|
||||
prev && prev.node?.focus();
|
||||
prev?.node?.focus();
|
||||
};
|
||||
const firstItem = () => {
|
||||
const first = descendants.firstEnabled();
|
||||
first && first.node?.focus();
|
||||
first?.node?.focus();
|
||||
};
|
||||
const lastItem = () => {
|
||||
const last = descendants.lastEnabled();
|
||||
last && last.node?.focus();
|
||||
last?.node?.focus();
|
||||
};
|
||||
|
||||
const keyMap: Record<string, React.KeyboardEventHandler> = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
FlipOptions,
|
||||
Placement,
|
||||
autoUpdate,
|
||||
flip,
|
||||
@@ -217,9 +218,6 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
) => {
|
||||
const context = useFloatingContext();
|
||||
|
||||
const [content, setContent] = useState<HTMLDivElement | null>(null);
|
||||
const mergeRefs = useMergeRefs(forwardedRef, setContent);
|
||||
|
||||
const arrowDefaults = {
|
||||
padding: 5,
|
||||
width: 0,
|
||||
@@ -251,11 +249,13 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
const detectOverflowOptions = {
|
||||
const detectOverflowOptions: FlipOptions = {
|
||||
padding: collisionPadding,
|
||||
boundary: boundary.filter(isNotNull),
|
||||
// with `strategy: 'fixed'`, this is the only way to get it to respect boundaries
|
||||
altBoundary: hasExplicitBoundaries,
|
||||
/* https://floating-ui.com/docs/flip#fallbackaxissidedirection */
|
||||
fallbackAxisSideDirection: "end",
|
||||
};
|
||||
|
||||
const { refs, floatingStyles, placement, isPositioned, middlewareData } =
|
||||
@@ -282,7 +282,6 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
mainAxis: true,
|
||||
crossAxis: false,
|
||||
limiter: limitShift(),
|
||||
...detectOverflowOptions,
|
||||
}),
|
||||
avoidCollisions && flip({ ...detectOverflowOptions }),
|
||||
size({
|
||||
@@ -332,11 +331,6 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
const arrowY = middlewareData.arrow?.y;
|
||||
const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0;
|
||||
|
||||
const [contentZIndex, setContentZIndex] = useState<string>();
|
||||
useClientLayoutEffect(() => {
|
||||
if (content) setContentZIndex(window.getComputedStyle(content).zIndex);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
@@ -347,7 +341,7 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
? floatingStyles.transform
|
||||
: "translate(0, -200%)", // keep off the page when measuring
|
||||
minWidth: "max-content",
|
||||
zIndex: contentZIndex,
|
||||
zIndex: "9999999",
|
||||
["--ac-floating-transform-origin" as any]: [
|
||||
middlewareData.transformOrigin?.x,
|
||||
middlewareData.transformOrigin?.y,
|
||||
@@ -365,7 +359,7 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
hideArrow={cannotCenterArrow}
|
||||
>
|
||||
<div
|
||||
ref={mergeRefs}
|
||||
ref={forwardedRef}
|
||||
data-side={placedSide}
|
||||
data-align={placedAlign}
|
||||
{...contentProps}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { Middleware, Placement } from "@floating-ui/react-dom";
|
||||
|
||||
const SIDE_OPTIONS = ["top", "right", "bottom", "left"] as const;
|
||||
const ALIGN_OPTIONS = ["start", "center", "end"] as const;
|
||||
|
||||
type Side = (typeof SIDE_OPTIONS)[number];
|
||||
type Align = (typeof ALIGN_OPTIONS)[number];
|
||||
type Side = "top" | "right" | "bottom" | "left";
|
||||
type Align = "start" | "center" | "end";
|
||||
type Measurable = { getBoundingClientRect(): DOMRect };
|
||||
|
||||
/**
|
||||
|
||||
@@ -69,7 +69,10 @@ export const getSteps = ({
|
||||
count,
|
||||
boundaryCount = 1,
|
||||
siblingCount = 1,
|
||||
}) => {
|
||||
}: Pick<
|
||||
PaginationProps,
|
||||
"page" | "count" | "boundaryCount" | "siblingCount"
|
||||
>) => {
|
||||
const range = (start: number, end: number) =>
|
||||
Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import faker from "faker";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getSteps } from "./Pagination";
|
||||
|
||||
describe("getSteps", () => {
|
||||
test("lists all pages when count is <= 7", () => {
|
||||
const count = faker.datatype.number({ min: 1, max: 7 });
|
||||
const count = 5;
|
||||
expect(getSteps({ page: 1, count })).toEqual(
|
||||
Array.from({ length: count }, (_, i) => i + 1),
|
||||
);
|
||||
});
|
||||
|
||||
test("has an end ellipsis when count >= 8", () => {
|
||||
const count = faker.datatype.number({ min: 8 });
|
||||
const page = faker.datatype.number({ min: 1, max: 4 });
|
||||
const count = 8;
|
||||
const page = 3;
|
||||
expect(
|
||||
getSteps({
|
||||
page,
|
||||
@@ -22,8 +21,8 @@ describe("getSteps", () => {
|
||||
});
|
||||
|
||||
test("has a start ellipsis when count - page >= 3", () => {
|
||||
const count = faker.datatype.number({ min: 8 });
|
||||
const page = faker.datatype.number({ min: count - 3, max: count });
|
||||
const count = 8;
|
||||
const page = 5;
|
||||
expect(
|
||||
getSteps({
|
||||
page,
|
||||
@@ -41,8 +40,8 @@ describe("getSteps", () => {
|
||||
});
|
||||
|
||||
test("has start & end ellipsis when count is high", () => {
|
||||
const count = faker.datatype.number({ min: 9 });
|
||||
const page = faker.datatype.number({ min: 5, max: count - 4 });
|
||||
const count = 9;
|
||||
const page = 5;
|
||||
expect(
|
||||
getSteps({
|
||||
page,
|
||||
@@ -52,8 +51,8 @@ describe("getSteps", () => {
|
||||
});
|
||||
|
||||
test("can have a reduced siblingCount", () => {
|
||||
const count = faker.datatype.number({ min: 7 });
|
||||
const page = faker.datatype.number({ min: 4, max: count - 3 });
|
||||
const count = 7;
|
||||
const page = 4;
|
||||
expect(
|
||||
getSteps({
|
||||
page,
|
||||
@@ -64,8 +63,8 @@ describe("getSteps", () => {
|
||||
});
|
||||
|
||||
test("can have an increased siblingCount", () => {
|
||||
const count = faker.datatype.number({ min: 11 });
|
||||
const page = faker.datatype.number({ min: 6, max: count - 5 });
|
||||
const count = 11;
|
||||
const page = 6;
|
||||
expect(
|
||||
getSteps({
|
||||
page,
|
||||
@@ -86,8 +85,8 @@ describe("getSteps", () => {
|
||||
});
|
||||
|
||||
test("can have an reduced boundaryCount", () => {
|
||||
const count = faker.datatype.number({ min: 7 });
|
||||
const page = faker.datatype.number({ min: 4, max: count - 3 });
|
||||
const count = 7;
|
||||
const page = 4;
|
||||
expect(
|
||||
getSteps({
|
||||
page,
|
||||
@@ -98,8 +97,8 @@ describe("getSteps", () => {
|
||||
});
|
||||
|
||||
test("can have an increased boundaryCount", () => {
|
||||
const count = faker.datatype.number({ min: 11 });
|
||||
const page = faker.datatype.number({ min: 6, max: count - 5 });
|
||||
const count = 11;
|
||||
const page = 6;
|
||||
expect(
|
||||
getSteps({
|
||||
page,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { createContext, useContext } from "react";
|
||||
import { TranslationDictionary } from "../../util/i18n/i18n.types";
|
||||
import { PartialTranslations } from "../../util/i18n/i18n.types";
|
||||
import nb from "../../util/i18n/locales/nb";
|
||||
|
||||
export interface LanguageProviderContextType {
|
||||
/**
|
||||
* Merged with the default language translations object (officially provided translations).
|
||||
*/
|
||||
translations: TranslationDictionary | TranslationDictionary[];
|
||||
translations: PartialTranslations | PartialTranslations[];
|
||||
}
|
||||
|
||||
export const LanguageProviderContext =
|
||||
@@ -16,7 +16,7 @@ export const LanguageProviderContext =
|
||||
|
||||
export interface LanguageProviderProps {
|
||||
children?: React.ReactNode;
|
||||
translations?: TranslationDictionary | TranslationDictionary[];
|
||||
translations?: PartialTranslations | PartialTranslations[];
|
||||
}
|
||||
|
||||
export const useProvider = () => useContext(LanguageProviderContext);
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export interface BodyProps
|
||||
extends React.HTMLAttributes<HTMLTableSectionElement> {}
|
||||
export type BodyProps = React.HTMLAttributes<HTMLTableSectionElement>;
|
||||
|
||||
export interface BodyType
|
||||
extends React.ForwardRefExoticComponent<
|
||||
BodyProps & React.RefAttributes<HTMLTableSectionElement>
|
||||
> {}
|
||||
export type BodyType = React.ForwardRefExoticComponent<
|
||||
BodyProps & React.RefAttributes<HTMLTableSectionElement>
|
||||
>;
|
||||
|
||||
export const Body: BodyType = forwardRef(({ className, ...rest }, ref) => (
|
||||
<tbody {...rest} ref={ref} className={cl("navds-table__body", className)} />
|
||||
|
||||
@@ -19,10 +19,9 @@ export interface ColumnHeaderProps extends HeaderCellProps {
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface ColumnHeaderType
|
||||
extends React.ForwardRefExoticComponent<
|
||||
ColumnHeaderProps & React.RefAttributes<HTMLTableCellElement>
|
||||
> {}
|
||||
export type ColumnHeaderType = React.ForwardRefExoticComponent<
|
||||
ColumnHeaderProps & React.RefAttributes<HTMLTableCellElement>
|
||||
>;
|
||||
|
||||
export const ColumnHeader: ColumnHeaderType = forwardRef(
|
||||
({ className, children, sortable = false, sortKey, ...rest }, ref) => {
|
||||
|
||||
@@ -49,10 +49,9 @@ export interface ExpandableRowProps extends Omit<RowProps, "content"> {
|
||||
colSpan?: number;
|
||||
}
|
||||
|
||||
export interface ExpandableRowType
|
||||
extends React.ForwardRefExoticComponent<
|
||||
ExpandableRowProps & React.RefAttributes<HTMLTableRowElement>
|
||||
> {}
|
||||
export type ExpandableRowType = React.ForwardRefExoticComponent<
|
||||
ExpandableRowProps & React.RefAttributes<HTMLTableRowElement>
|
||||
>;
|
||||
|
||||
export const ExpandableRow: ExpandableRowType = forwardRef(
|
||||
(
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export interface HeaderProps
|
||||
extends React.HTMLAttributes<HTMLTableSectionElement> {}
|
||||
export type HeaderProps = React.HTMLAttributes<HTMLTableSectionElement>;
|
||||
|
||||
export interface HeaderType
|
||||
extends React.ForwardRefExoticComponent<
|
||||
HeaderProps & React.RefAttributes<HTMLTableSectionElement>
|
||||
> {}
|
||||
export type HeaderType = React.ForwardRefExoticComponent<
|
||||
HeaderProps & React.RefAttributes<HTMLTableSectionElement>
|
||||
>;
|
||||
|
||||
export const Header: HeaderType = forwardRef(({ className, ...rest }, ref) => (
|
||||
<thead {...rest} ref={ref} className={cl("navds-table__header", className)} />
|
||||
|
||||
@@ -15,10 +15,9 @@ export interface HeaderCellProps
|
||||
textSize?: "medium" | "small";
|
||||
}
|
||||
|
||||
export interface HeaderCellType
|
||||
extends React.ForwardRefExoticComponent<
|
||||
HeaderCellProps & React.RefAttributes<HTMLTableCellElement>
|
||||
> {}
|
||||
export type HeaderCellType = React.ForwardRefExoticComponent<
|
||||
HeaderCellProps & React.RefAttributes<HTMLTableCellElement>
|
||||
>;
|
||||
|
||||
export const HeaderCell: HeaderCellType = forwardRef(
|
||||
({ className, children, align, textSize, ...rest }, ref) => {
|
||||
|
||||
@@ -14,10 +14,9 @@ export interface RowProps extends React.HTMLAttributes<HTMLTableRowElement> {
|
||||
shadeOnHover?: boolean;
|
||||
}
|
||||
|
||||
export interface RowType
|
||||
extends React.ForwardRefExoticComponent<
|
||||
RowProps & React.RefAttributes<HTMLTableRowElement>
|
||||
> {}
|
||||
export type RowType = React.ForwardRefExoticComponent<
|
||||
RowProps & React.RefAttributes<HTMLTableRowElement>
|
||||
>;
|
||||
|
||||
export const Row: RowType = forwardRef(
|
||||
({ className, selected = false, shadeOnHover = true, ...rest }, ref) => (
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useScrollButtons(listRef: React.RefObject<HTMLDivElement>) {
|
||||
|
||||
return () => {
|
||||
win.removeEventListener("resize", handleResize);
|
||||
resizeObserver && resizeObserver.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
updateScrollButtonState.clear();
|
||||
};
|
||||
}, [listRef, updateScrollButtonState]);
|
||||
|
||||
@@ -26,19 +26,19 @@ export function useTabList() {
|
||||
|
||||
const nextTab = () => {
|
||||
const next = descendants.nextEnabled(idx, loop);
|
||||
next && next.node?.focus();
|
||||
next?.node?.focus();
|
||||
};
|
||||
const prevTab = () => {
|
||||
const prev = descendants.prevEnabled(idx, loop);
|
||||
prev && prev.node?.focus();
|
||||
prev?.node?.focus();
|
||||
};
|
||||
const firstTab = () => {
|
||||
const first = descendants.firstEnabled();
|
||||
first && first.node?.focus();
|
||||
first?.node?.focus();
|
||||
};
|
||||
const lastTab = () => {
|
||||
const last = descendants.lastEnabled();
|
||||
last && last.node?.focus();
|
||||
last?.node?.focus();
|
||||
};
|
||||
|
||||
const keyMap: Record<string, React.KeyboardEventHandler> = {
|
||||
|
||||
@@ -91,27 +91,26 @@ export const TimelineRow = forwardRef<HTMLOListElement, TimelineRowProps>(
|
||||
}
|
||||
}}
|
||||
>
|
||||
{periods &&
|
||||
periods.map((period) => {
|
||||
return (
|
||||
<li key={`period-${period.id}`}>
|
||||
<PeriodContext.Provider
|
||||
value={{
|
||||
periodId: period.id,
|
||||
firstFocus: firstFocusable?.id === period.id,
|
||||
restProps: period?.restProps,
|
||||
}}
|
||||
>
|
||||
<Period
|
||||
start={period.start}
|
||||
end={period.endInclusive}
|
||||
icon={period.icon}
|
||||
ref={period?.ref}
|
||||
/>
|
||||
</PeriodContext.Provider>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{periods?.map((period) => {
|
||||
return (
|
||||
<li key={`period-${period.id}`}>
|
||||
<PeriodContext.Provider
|
||||
value={{
|
||||
periodId: period.id,
|
||||
firstFocus: firstFocusable?.id === period.id,
|
||||
restProps: period?.restProps,
|
||||
}}
|
||||
>
|
||||
<Period
|
||||
start={period.start}
|
||||
end={period.endInclusive}
|
||||
icon={period.icon}
|
||||
ref={period?.ref}
|
||||
/>
|
||||
</PeriodContext.Provider>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -55,19 +55,19 @@ export function useToggleItem<P extends UseToggleItemProps>(
|
||||
|
||||
const nextTab = () => {
|
||||
const next = descendants.nextEnabled(idx, false);
|
||||
next && next.node?.focus();
|
||||
next?.node?.focus();
|
||||
};
|
||||
const prevTab = () => {
|
||||
const prev = descendants.prevEnabled(idx, false);
|
||||
prev && prev.node?.focus();
|
||||
prev?.node?.focus();
|
||||
};
|
||||
const firstTab = () => {
|
||||
const first = descendants.firstEnabled();
|
||||
first && first.node?.focus();
|
||||
first?.node?.focus();
|
||||
};
|
||||
const lastTab = () => {
|
||||
const last = descendants.lastEnabled();
|
||||
last && last.node?.focus();
|
||||
last?.node?.focus();
|
||||
};
|
||||
|
||||
const keyMap: Record<string, React.KeyboardEventHandler> = {
|
||||
|
||||
@@ -152,14 +152,14 @@ export const FontWeight: Story = {
|
||||
return (
|
||||
<>
|
||||
{fontWeights.map((weight) => (
|
||||
<>
|
||||
<React.Fragment key={weight}>
|
||||
<h2>{weight}</h2>
|
||||
<FontComponent
|
||||
key={weight}
|
||||
weight={weight}
|
||||
family="Source Sans 3"
|
||||
/>
|
||||
</>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ const checkState = (
|
||||
* https://github.com/mui/material-ui/blob/master/packages/mui-utils/src/ownerWindow/ownerWindow.ts
|
||||
*/
|
||||
const ownerWindow = (node: Node | undefined): Window => {
|
||||
const doc = (node && node.ownerDocument) || document;
|
||||
const doc = node?.ownerDocument || document;
|
||||
return doc.defaultView || window;
|
||||
};
|
||||
|
||||
@@ -223,7 +223,7 @@ const TextareaAutosize = forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>(
|
||||
};
|
||||
|
||||
const mainStyle: React.CSSProperties = {
|
||||
["--__ac-textarea-height"]: state.outerHeightStyle + "px",
|
||||
"--__ac-textarea-height": state.outerHeightStyle + "px",
|
||||
// Need a large enough difference to allow scrolling.
|
||||
// This prevents infinite rendering loop.
|
||||
overflow:
|
||||
|
||||
@@ -60,7 +60,7 @@ export class DescendantsManager<
|
||||
this.descendants.forEach((descendant) => {
|
||||
const index = descendants.indexOf(descendant.node);
|
||||
descendant.index = index;
|
||||
descendant.node.dataset["index"] = descendant.index.toString();
|
||||
descendant.node.dataset.index = descendant.index.toString();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export function createDescendantContext<
|
||||
|
||||
useClientLayoutEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const dataIndex = Number(ref.current.dataset["index"]);
|
||||
const dataIndex = Number(ref.current.dataset.index);
|
||||
if (index !== dataIndex && !Number.isNaN(dataIndex)) {
|
||||
setIndex(dataIndex);
|
||||
}
|
||||
|
||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user