Compare commits

...

37 Commits

Author SHA1 Message Date
Cory LaViska
3217ce9f76 resolve feedback 2023-08-21 10:56:25 -04:00
Cory LaViska
c6d69fdbd6 update changelog 2023-08-18 14:47:58 -04:00
Cory LaViska
77b40fa7b4 Merge branch 'next' into submenus 2023-08-18 14:43:17 -04:00
Cory LaViska
bebf74a1fb stay highlighted when submenu is open 2023-08-18 14:40:21 -04:00
Cory LaViska
f524d20216 polish up submenu stuff 2023-08-18 14:18:38 -04:00
Konnor Rogers
539eaded73 Update React Wrappers with Refs that work (#1526)
* fix react types for refs

* fix displayName

* fix displayName]

* attempt to fix typings for React refs

* fix bad type

* prettier

* add changelog entry

* prettier
2023-08-18 13:31:50 -04:00
Cory LaViska
021c32ab32 Merge branch 'next-eycrb' of github.com:ecyrb/shoelace-ecyrb-fork into ecyrb-next-eycrb 2023-08-18 13:04:14 -04:00
Cory LaViska
93b2e78092 Merge branch 'nathangray-next' into next 2023-08-18 12:05:47 -04:00
Cory LaViska
402a00dcd3 update docs 2023-08-18 12:05:22 -04:00
Cory LaViska
b63368d5f6 Merge branch 'next' of github.com:nathangray/shoelace into nathangray-next 2023-08-18 11:23:56 -04:00
Cory LaViska
74c6d3ee36 fix tree tests; #1521 2023-08-18 11:20:14 -04:00
nathan
621aa4362b Add HTMLElement to the getTag() return type 2023-08-18 09:17:02 -06:00
Cory LaViska
c8919ad11f prettier 2023-08-18 09:55:57 -04:00
Stephen Sugden
fad76dd1a2 SlTree: separate expand/collapse and selection behaviour in 'single' mode (#1521)
* Never select tree items when clicking the chevron

This changes the behaviour of sl-tree so that clicking on the expand/collapse icon will not select/deselect the item, only toggle it's expanded state.

* Refactor: inline SlTree.syncTreeItems

This was only called from 2 places, and they each had different
behaviour anyways.

* SlTree: separate expand/collapse from selection

This makes 'multi' and 'single' mode consistent with each other, and
with native file managers.
2023-08-18 09:55:29 -04:00
nathan
b2f6499b87 Fix lint warnings 2023-08-17 13:18:51 -06:00
nathan
9520e850dd Update for path changes
see 3a61d20d93
2023-08-17 11:34:25 -06:00
Cory LaViska
4ee5271a83 Merge branch 'next' of https://github.com/shoelace-style/shoelace into next 2023-08-16 15:01:46 -04:00
Thomas Allmer
d8de7bcc51 fix(docs): Inline Form Validation Docs throw error on top level await (#1522) 2023-08-16 14:59:21 -04:00
Cory LaViska
7ee31be6d6 ignore package.json 2023-08-16 14:57:03 -04:00
Cory LaViska
9cb5ba7ac1 Radio button fix (#1524)
* fix formatting

* fix radio button spacing; fixes #1523
2023-08-16 14:51:46 -04:00
Peter Siska
c380368b61 Fix NPMDIR config (#1518)
* Fix NPMDIR config

* Add missing semi
2023-08-15 10:46:51 -04:00
Konnor Rogers
e298f7e5f4 fix broken tests for shoelace-element (#1516)
* add stub code prior to test

* fix broken test

* prettier

* prettier

* prettier
2023-08-14 11:23:00 -04:00
Cory LaViska
c743561c25 update docs 2023-08-14 10:23:59 -04:00
Alexander Krolick
e73e32fb71 Add docs on setting multiple values in select (#1508) 2023-08-14 10:21:52 -04:00
Cory LaViska
b09a48bec4 fix arg name 2023-08-14 10:02:23 -04:00
Burton Smith
aeef986cf5 JetBrains IDE Integration (#1512)
* upgrade vs code integration package

* add references

* add web-types plugin

* update reference

* run prettier

* update documentation

* run prettier

* remove test script
2023-08-14 09:34:34 -04:00
Bryce Moore
dbf506d78f Submenu tweaks ...
- 100 ms delay when opening submenus on mouseover
- Shadows added
- Distance added to popup to have submenus overlap menu slightly.
2023-07-14 15:13:07 -04:00
Bryce Moore
1d826a9c53 fix: Prevent menu's extraneous Enter / space key propagation.
Menu's handleKeyDown calls item.click (to emit the selection).
Propagating the keyboard event on Enter / space would the cause re-entry
into a submenu, so prevent the needless propagation.
2023-07-13 09:55:45 -04:00
Bryce Moore
58d6cad39a fix: 2 changes to menu / submenu on-click behavior:
1. Close submenu on click explicitly, so this occurs even if the menu is
   not inside of an sl-dropdown.

2. In menu, ignore clicks that do not explicitly target a menu-item.
   Clicks that were on (e.g. a menu-border) were emitting select events.
2023-07-13 08:49:10 -04:00
Bryce Moore
e0e96c8a0a style: Eslint warnings and errors fixed. npm run verify now passes. 2023-07-13 06:26:44 -04:00
Bryce Moore
25a146ffd1 Cleanup: Removed dead code and dead code comments. 2023-07-11 02:50:48 -04:00
Bryce Moore
c55ba0467e Merge branch 'next' into next-eycrb 2023-07-08 08:16:46 -04:00
Bryce Moore
126b0e34bd [WIP] Submenu WIP continues.
- Submenus now close on change-of-focus, not a timeout.
- Keyboard navigation support added.
- Skidding fix for better alignment.
- Submenu documentation moved to Menu page.
- Tests for accessibility, right and left arrow keys.
2023-07-07 09:43:40 -04:00
Bryce Moore
7aca8d7300 Revert "PoC working of ArrowRight to focus on submenu."
(Didn't mean to publish this.)

This reverts commit be04e9a221.
2023-06-30 09:52:54 -04:00
Bryce Moore
be04e9a221 PoC working of ArrowRight to focus on submenu. 2023-06-30 07:44:55 -04:00
Bryce
354404484e Update submenu-controller.ts
Removed extraneous `console.log()`.
2023-06-28 02:43:51 -04:00
Bryce Moore
b48d072bc8 [RFC] Proof-of-concept commit for submenu support
This is a Request For Comments to seek directional guidance towards
implementing the submenu slot of menu-item.

Includes:
- SubmenuController to manage event listeners on menu-item.
- Example usage in menu-item documentation.
- Trivial tests to check rendering.

Outstanding questions include:
- Accessibility concerns. E.g. where to handle 'ArrowRight',
  'ArrowLeft'?
- Should selection of menu-item denoting submenu be possible or
  customizable?
- How to parameterize contained popup?
- Implementation concerns:
  - Use of ref / id
  - delegation of some rendering to the controller
  - What to test

Related to [#620](https://github.com/shoelace-style/shoelace/issues/620).
2023-06-28 01:08:37 -04:00
29 changed files with 860 additions and 180 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ docs/assets/images/sprite.svg
node_modules
src/react
cdn
web-types.json

View File

@@ -7,6 +7,7 @@ docs/search.json
src/components/icon/icons
src/react/index.ts
node_modules
package.json
package-lock.json
tsconfig.json
cdn

View File

@@ -160,6 +160,7 @@
"unbundles",
"unbundling",
"unicons",
"unsanitized",
"unsupportive",
"valpha",
"valuenow",

View File

@@ -1,4 +1,5 @@
import * as path from 'path';
import { customElementJetBrainsPlugin } from 'custom-element-jet-brains-integration';
import { customElementVsCodePlugin } from 'custom-element-vs-code-integration';
import { parse } from 'comment-parser';
import { pascalCase } from 'pascal-case';
@@ -200,6 +201,15 @@ export default {
url: `https://shoelace.style/components/${tag.replace('sl-', '')}`
}
]
}),
customElementJetBrainsPlugin({
excludeCss: true,
referencesTemplate: (_, tag) => {
return {
name: 'Documentation',
url: `https://shoelace.style/components/${tag.replace('sl-', '')}`
};
}
})
]
};

View File

@@ -310,6 +310,96 @@ const App = () => (
);
```
### Submenus
To create a submenu, nest an `<sl-menu slot="submenu">` element in a [menu item](/components/menu-item).
```html:preview
<sl-dropdown>
<sl-button slot="trigger" caret>Edit</sl-button>
<sl-menu style="max-width: 200px;">
<sl-menu-item value="undo">Undo</sl-menu-item>
<sl-menu-item value="redo">Redo</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item value="cut">Cut</sl-menu-item>
<sl-menu-item value="copy">Copy</sl-menu-item>
<sl-menu-item value="paste">Paste</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item>
Find
<sl-menu slot="submenu">
<sl-menu-item value="find">Find…</sl-menu-item>
<sl-menu-item value="find-previous">Find Next</sl-menu-item>
<sl-menu-item value="find-next">Find Previous</sl-menu-item>
</sl-menu>
</sl-menu-item>
<sl-menu-item>
Transformations
<sl-menu slot="submenu">
<sl-menu-item value="uppercase">Make uppercase</sl-menu-item>
<sl-menu-item value="lowercase">Make lowercase</sl-menu-item>
<sl-menu-item value="capitalize">Capitalize</sl-menu-item>
</sl-menu>
</sl-menu-item>
</sl-menu>
</sl-dropdown>
```
```jsx:react
import SlButton from '@shoelace-style/shoelace/dist/react/button';
import SlDivider from '@shoelace-style/shoelace/dist/react/divider';
import SlDropdown from '@shoelace-style/shoelace/dist/react/dropdown';
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';
const css = `
.dropdown-hoist {
border: solid 2px var(--sl-panel-border-color);
padding: var(--sl-spacing-medium);
overflow: hidden;
}
`;
const App = () => (
<>
<SlDropdown>
<SlButton slot="trigger" caret>Edit</SlButton>
<SlMenu style="max-width: 200px;">
<SlMenuItem value="undo">Undo</SlMenuItem>
<SlMenuItem value="redo">Redo</SlMenuItem>
<SlDivider />
<SlMenuItem value="cut">Cut</SlMenuItem>
<SlMenuItem value="copy">Copy</SlMenuItem>
<SlMenuItem value="paste">Paste</SlMenuItem>
<SlDivider />
<SlMenuItem>
Find
<SlMenu slot="submenu">
<SlMenuItem value="find">Find…</SlMenuItem>
<SlMenuItem value="find-previous">Find Next</SlMenuItem>
<SlMenuItem value="find-next">Find Previous</SlMenuItem>
</SlMenu>
</SlMenuItem>
<SlMenuItem>
Transformations
<SlMenu slot="submenu">
<SlMenuItem value="uppercase">Make uppercase</SlMenuItem>
<SlMenuItem value="lowercase">Make lowercase</SlMenuItem>
<SlMenuItem value="capitalize">Capitalize</SlMenuItem>
</SlMenu>
</SlMenuItem>
</SlMenu>
</SlDropdown>
</>
);
```
:::warning
As a UX best practice, avoid using more than one level of submenu when possible.
:::
### Hoisting
Dropdown panels will be clipped if they're inside a container that has `overflow: auto|hidden`. The `hoist` attribute forces the panel to use a fixed positioning strategy, allowing it to break out of the container. In this case, the panel will be positioned relative to its [containing block](https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#Identifying_the_containing_block), which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details.
@@ -349,7 +439,6 @@ Dropdown panels will be clipped if they're inside a container that has `overflow
import SlButton from '@shoelace-style/shoelace/dist/react/button';
import SlDivider from '@shoelace-style/shoelace/dist/react/divider';
import SlDropdown from '@shoelace-style/shoelace/dist/react/dropdown';
import SlIcon from '@shoelace-style/shoelace/dist/react/icon';
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';

View File

@@ -44,3 +44,112 @@ const App = () => (
:::tip
Menus are intended for system menus (dropdown menus, select menus, context menus, etc.). They should not be mistaken for navigation menus which serve a different purpose and have a different semantic meaning. If you're building navigation, use `<nav>` and `<a>` elements instead.
:::
## Examples
### In Dropdowns
Menus work really well when used inside [dropdowns](/components/dropdown).
```html:preview
<sl-dropdown>
<sl-button slot="trigger" caret>Edit</sl-button>
<sl-menu>
<sl-menu-item value="cut">Cut</sl-menu-item>
<sl-menu-item value="copy">Copy</sl-menu-item>
<sl-menu-item value="paste">Paste</sl-menu-item>
</sl-menu>
</sl-dropdown>
```
```jsx:react
import SlButton from '@shoelace-style/shoelace/dist/react/button';
import SlDropdown from '@shoelace-style/shoelace/dist/react/dropdown';
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';
const App = () => (
<SlDropdown>
<SlButton slot="trigger" caret>Edit</SlButton>
<SlMenu>
<SlMenuItem value="cut">Cut</SlMenuItem>
<SlMenuItem value="copy">Copy</SlMenuItem>
<SlMenuItem value="paste">Paste</SlMenuItem>
</SlMenu>
</SlDropdown>
);
```
### Submenus
To create a submenu, nest an `<sl-menu slot="submenu">` in any [menu item](/components/menu-item).
```html:preview
<sl-menu style="max-width: 200px;">
<sl-menu-item value="undo">Undo</sl-menu-item>
<sl-menu-item value="redo">Redo</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item value="cut">Cut</sl-menu-item>
<sl-menu-item value="copy">Copy</sl-menu-item>
<sl-menu-item value="paste">Paste</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item>
Find
<sl-menu slot="submenu">
<sl-menu-item value="find">Find…</sl-menu-item>
<sl-menu-item value="find-previous">Find Next</sl-menu-item>
<sl-menu-item value="find-next">Find Previous</sl-menu-item>
</sl-menu>
</sl-menu-item>
<sl-menu-item>
Transformations
<sl-menu slot="submenu">
<sl-menu-item value="uppercase">Make uppercase</sl-menu-item>
<sl-menu-item value="lowercase">Make lowercase</sl-menu-item>
<sl-menu-item value="capitalize">Capitalize</sl-menu-item>
</sl-menu>
</sl-menu-item>
</sl-menu>
```
{% raw %}
```jsx:react
import SlDivider from '@shoelace-style/shoelace/dist/react/divider';
import SlMenu from '@shoelace-style/shoelace/dist/react/menu';
import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item';
const App = () => (
<SlMenu style={{ maxWidth: '200px' }}>
<SlMenuItem value="undo">Undo</SlMenuItem>
<SlMenuItem value="redo">Redo</SlMenuItem>
<SlDivider />
<SlMenuItem value="cut">Cut</SlMenuItem>
<SlMenuItem value="copy">Copy</SlMenuItem>
<SlMenuItem value="paste">Paste</SlMenuItem>
<SlDivider />
<SlMenuItem>
Find
<SlMenu slot="submenu">
<SlMenuItem value="find">Find…</SlMenuItem>
<SlMenuItem value="find-previous">Find Next</SlMenuItem>
<SlMenuItem value="find-next">Find Previous</SlMenuItem>
</SlMenu>
</SlMenuItem>
<SlMenuItem>
Transformations
<SlMenu slot="submenu">
<SlMenuItem value="uppercase">Make uppercase</SlMenuItem>
<SlMenuItem value="lowercase">Make lowercase</SlMenuItem>
<SlMenuItem value="capitalize">Capitalize</SlMenuItem>
</SlMenu>
</SlMenuItem>
</SlMenu>
);
```
:::warning
As a UX best practice, avoid using more than one level of submenus when possible.
:::
{% endraw %}

View File

@@ -250,7 +250,9 @@ Note that multi-select options may wrap, causing the control to expand verticall
### Setting Initial Values
Use the `value` attribute to set the initial selection. When using `multiple`, use space-delimited values to select more than one option.
Use the `value` attribute to set the initial selection.
When using `multiple`, the `value` _attribute_ uses space-delimited values to select more than one option. Because of this, `<sl-option>` values cannot contain spaces. If you're accessing the `value` _property_ through Javascript, it will be an array.
```html:preview
<sl-select value="option-1 option-2" multiple clearable>
@@ -381,10 +383,8 @@ The preferred placement of the select's listbox can be set with the `placement`
```
```jsx:react
import {
SlOption,
SlSelect
} from '@shoelace-style/shoelace/dist/react';
import SlOption from '@shoelace-style/shoelace/dist/react/option';
import SlSelect from '@shoelace-style/shoelace/dist/react/select';
const App = () => (
<SlSelect placement="top">
@@ -452,3 +452,53 @@ const App = () => (
</>
);
```
### Custom Tags
When multiple options can be selected, you can provide custom tags by passing a function to the `getTag` property. Your function can return a string of HTML, a <a href="https://lit.dev/docs/templates/overview/">Lit Template</a>, or an [`HTMLElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement). The `getTag()` function will be called for each option. The first argument is an `<sl-option>` element and the second argument is the tag's index (its position in the tag list).
Remember that custom tags are rendered in a shadow root. To style them, you can use the `style` attribute in your template or you can add your own [parts](/getting-started/customizing/#css-parts) and target them with the [`::part()`](https://developer.mozilla.org/en-US/docs/Web/CSS/::part) selector.
```html:preview
<sl-select
placeholder="Select one"
value="email phone"
multiple
clearable
class="custom-tag"
>
<sl-option value="email">
<sl-icon slot="prefix" name="envelope"></sl-icon>
Email
</sl-option>
<sl-option value="phone">
<sl-icon slot="prefix" name="telephone"></sl-icon>
Phone
</sl-option>
<sl-option value="chat">
<sl-icon slot="prefix" name="chat-dots"></sl-icon>
Chat
</sl-option>
</sl-select>
<script type="module">
const select = document.querySelector('.custom-tag');
select.getTag = (option, index) => {
// Use the same icon used in the <sl-option>
const name = option.querySelector('sl-icon[slot="prefix"]').name;
// You can return a string, a Lit Template, or an HTMLElement here
return `
<sl-tag removable>
<sl-icon name="${name}" style="padding-inline-end: .5rem;"></sl-icon>
${option.getTextLabel()}
</sl-tag>
`;
};
</script>
```
:::warning
Be sure you trust the content you are outputting! Passing unsanitized user input to `getTag()` can result in XSS vulnerabilities.
:::

View File

@@ -462,7 +462,7 @@ To disable the browser's error messages, you need to cancel the `sl-invalid` eve
<sl-button type="reset" variant="default">Reset</sl-button>
</form>
<script>
<script type="module">
const form = document.querySelector('.inline-validation');
const nameError = document.querySelector('#name-error');

View File

@@ -187,7 +187,7 @@ import '@shoelace-style/shoelace/%NPMDIR%/components/rating/rating.js';
import { setBasePath } from '@shoelace-style/shoelace/%NPMDIR%/utilities/base-path.js';
// Set the base path to the folder you copied Shoelace's assets to
setBasePath('/path/to/shoelace/%NPMDIR%
setBasePath('/path/to/shoelace/%NPMDIR%');
// <sl-button>, <sl-icon>, <sl-input>, and <sl-rating> are ready to use!
```

View File

@@ -210,6 +210,12 @@ Shoelace ships with a file called `vscode.html-custom-data.json` that can be use
If `settings.json` already exists, simply add the above line to the root of the object. Note that you may need to restart VS Code for the changes to take affect.
## JetBrains IDEs
If you are using a [JetBrains IDE](https://www.jetbrains.com/) and you are installing Shoelace from NPM, the editor will automatically detect the `web-types.json` file from the package and you should immediately see component information in your editor.
If you are installing from the CDN, you can [download a local copy](https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace/cdn/web-types.json) and add it to the root of your project.
### Other Editors
Most popular editors support custom code completion with a bit of configuration. Please [submit a feature request](https://github.com/shoelace-style/shoelace/issues/new/choose) for your editor of choice. PRs are also welcome!

View File

@@ -12,6 +12,15 @@ Components with the <sl-badge variant="warning" pill>Experimental</sl-badge> bad
New versions of Shoelace are released as-needed and generally occur when a critical mass of changes have accumulated. At any time, you can see what's coming in the next release by visiting [next.shoelace.style](https://next.shoelace.style).
## Next
- Added support for submenus in `<sl-menu-item>` [#1410]
- Added the `--submenu-offset` custom property to `<sl-menu-item>` [#1410]
- Fixed type issues with the `ref` attribute in React Wrappers. [#1526]
- Fixed a regression that caused `<sl-radio-button>` to render incorrectly with gaps [#1523]
- Improved expand/collapse behavior of `<sl-tree>` to work more like users expect [#1521]
- Improved `<sl-menu-item>` so labels truncate properly instead of getting chopped and overflowing
## 2.7.0
- Added the experimental `<sl-copy-button>` component [#1473]

33
package-lock.json generated
View File

@@ -11,7 +11,7 @@
"dependencies": {
"@ctrl/tinycolor": "^3.5.0",
"@floating-ui/dom": "^1.2.1",
"@lit-labs/react": "^1.1.1",
"@lit-labs/react": "^1.2.1",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.1.1",
"composed-offset-position": "^0.0.4",
@@ -37,6 +37,7 @@
"command-line-args": "^5.2.1",
"comment-parser": "^1.3.1",
"cspell": "^6.18.1",
"custom-element-jet-brains-integration": "^1.1.0",
"custom-element-vs-code-integration": "^1.1.0",
"del": "^7.0.0",
"download": "^8.0.0",
@@ -1473,9 +1474,9 @@
}
},
"node_modules/@lit-labs/react": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-1.1.1.tgz",
"integrity": "sha512-9TC+/ZWb6BJlWCyUr14FKFlaGnyKpeEDorufXozQgke/VoVrslUQNaL7nBmrAWdNrmzx5jWgi8lFmWwrxMjnlA=="
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-1.2.1.tgz",
"integrity": "sha512-DiZdJYFU0tBbdQkfwwRSwYyI/mcWkg3sWesKRsHUd4G+NekTmmeq9fzsurvcKTNVa0comNljwtg4Hvi1ds3V+A=="
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.1.1",
@@ -5687,6 +5688,15 @@
"integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==",
"dev": true
},
"node_modules/custom-element-jet-brains-integration": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/custom-element-jet-brains-integration/-/custom-element-jet-brains-integration-1.1.0.tgz",
"integrity": "sha512-wesa4OEvRQdxNzynk5ugU7ZRy0Ghkoaa6NmRGTqOASIng1hVaE3EKKO3rK11b4Y/pR3HUPIPKs1mRSnRCjHBfg==",
"dev": true,
"dependencies": {
"prettier": "^2.8.0"
}
},
"node_modules/custom-element-vs-code-integration": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/custom-element-vs-code-integration/-/custom-element-vs-code-integration-1.1.0.tgz",
@@ -18281,9 +18291,9 @@
}
},
"@lit-labs/react": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-1.1.1.tgz",
"integrity": "sha512-9TC+/ZWb6BJlWCyUr14FKFlaGnyKpeEDorufXozQgke/VoVrslUQNaL7nBmrAWdNrmzx5jWgi8lFmWwrxMjnlA=="
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-1.2.1.tgz",
"integrity": "sha512-DiZdJYFU0tBbdQkfwwRSwYyI/mcWkg3sWesKRsHUd4G+NekTmmeq9fzsurvcKTNVa0comNljwtg4Hvi1ds3V+A=="
},
"@lit-labs/ssr-dom-shim": {
"version": "1.1.1",
@@ -21507,6 +21517,15 @@
"integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==",
"dev": true
},
"custom-element-jet-brains-integration": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/custom-element-jet-brains-integration/-/custom-element-jet-brains-integration-1.1.0.tgz",
"integrity": "sha512-wesa4OEvRQdxNzynk5ugU7ZRy0Ghkoaa6NmRGTqOASIng1hVaE3EKKO3rK11b4Y/pR3HUPIPKs1mRSnRCjHBfg==",
"dev": true,
"requires": {
"prettier": "^2.8.0"
}
},
"custom-element-vs-code-integration": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/custom-element-vs-code-integration/-/custom-element-vs-code-integration-1.1.0.tgz",

View File

@@ -6,7 +6,7 @@
"author": "Cory LaViska",
"license": "MIT",
"customElements": "dist/custom-elements.json",
"web-types": "dist/web-types.json",
"web-types": "./web-types.json",
"type": "module",
"types": "dist/shoelace.d.ts",
"jsdelivr": "./cdn/shoelace-autoloader.js",
@@ -25,15 +25,8 @@
"./dist/react/*": "./dist/react/*",
"./dist/translations/*": "./dist/translations/*"
},
"files": [
"dist",
"cdn"
],
"keywords": [
"web components",
"custom elements",
"components"
],
"files": ["dist", "cdn"],
"keywords": ["web components", "custom elements", "components"],
"repository": {
"type": "git",
"url": "git+https://github.com/shoelace-style/shoelace.git"
@@ -69,7 +62,7 @@
"dependencies": {
"@ctrl/tinycolor": "^3.5.0",
"@floating-ui/dom": "^1.2.1",
"@lit-labs/react": "^1.1.1",
"@lit-labs/react": "^1.2.1",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.1.1",
"composed-offset-position": "^0.0.4",
@@ -95,6 +88,7 @@
"command-line-args": "^5.2.1",
"comment-parser": "^1.3.1",
"cspell": "^6.18.1",
"custom-element-jet-brains-integration": "^1.1.0",
"custom-element-vs-code-integration": "^1.1.0",
"del": "^7.0.0",
"download": "^8.0.0",
@@ -139,9 +133,6 @@
"user-agent-data-types": "^0.3.0"
},
"lint-staged": {
"*.{ts,js}": [
"eslint --max-warnings 0 --cache --fix",
"prettier --write"
]
"*.{ts,js}": ["eslint --max-warnings 0 --cache --fix", "prettier --write"]
}
}

View File

@@ -188,10 +188,6 @@ await nextTask('Wrapping components for React', () => {
return execPromise(`node scripts/make-react.js --outdir "${outdir}"`, { stdio: 'inherit' });
});
await nextTask('Generating Web Types', () => {
return execPromise(`node scripts/make-web-types.js --outdir "${outdir}"`, { stdio: 'inherit' });
});
await nextTask('Generating themes', () => {
return execPromise(`node scripts/make-themes.js --outdir "${outdir}"`, { stdio: 'inherit' });
});
@@ -207,6 +203,7 @@ await nextTask('Running the TypeScript compiler', () => {
// Copy the above steps to the CDN directory directly so we don't need to twice the work for nothing.
await nextTask(`Copying Web Types, Themes, Icons, and TS Types to "${cdndir}"`, async () => {
await deleteAsync(cdndir);
await copy('./web-types.json', `${outdir}/web-types.json`);
await copy(outdir, cdndir);
});

View File

@@ -51,6 +51,15 @@ components.map(component => {
${eventImports}
${eventExports}
export type ForwardComponent<
Element extends HTMLElement,
ReactComponent extends React.ElementType
> = React.JSXElementConstructor<
React.ComponentPropsWithoutRef<ReactComponent> & {
ref?: React.ForwardedRef<Element>;
}
> & { displayName?: string }
const tagName = '${component.tagName}'
const component = createComponent({
@@ -76,7 +85,7 @@ components.map(component => {
}
}
export default SlComponent;
export default SlComponent as ForwardComponent<Component, typeof SlComponent>;
`,
Object.assign(prettierConfig, {
parser: 'babel-ts'

View File

@@ -1,68 +0,0 @@
//
// This script generates a web-types.json file from custom-elements.json for use with WebStorm/PHPStorm
//
// Docs: https://github.com/JetBrains/web-types
//
import commandLineArgs from 'command-line-args';
import jsonata from 'jsonata';
import fs from 'fs';
import path from 'path';
const { outdir } = commandLineArgs({ name: 'outdir', type: String });
const metadata = JSON.parse(fs.readFileSync(path.join(outdir, 'custom-elements.json'), 'utf8'));
const jsonataExprString = `{
"$schema": "http://json.schemastore.org/web-types",
"name": package.name,
"version": package.version,
"description-markup": "markdown",
"framework-config": {
"enable-when": {
"node-packages": [
package.name
]
}
},
"contributions": {
"html": {
"elements": [
modules.declarations.{
"name": tagName,
"description": description,
"doc-url": $join(["https://shoelace.style/components/", $substringAfter(tagName, 'sl-')]),
"js": {
"properties": [
members.{
"name": name,
"description": description,
"value": {
"type": type.text
}
}
],
"events": [
events.{
"name": name,
"description": description
}
]
},
"attributes": [
attributes.{
"name": name,
"description": description,
"value": {
"type": type.text
}
}
]
}
]
}
}
}`;
const expression = jsonata(jsonataExprString);
const result = await expression.evaluate(metadata);
fs.writeFileSync(path.join(outdir, 'web-types.json'), JSON.stringify(result, null, 2), 'utf8');

View File

@@ -54,7 +54,7 @@ export default class SlButtonGroup extends ShoelaceElement {
const index = slottedElements.indexOf(el);
const button = findButton(el);
if (button !== null) {
if (button) {
button.classList.add('sl-button-group__button');
button.classList.toggle('sl-button-group__button--first', index === 0);
button.classList.toggle('sl-button-group__button--inner', index > 0 && index < slottedElements.length - 1);

View File

@@ -90,15 +90,15 @@ export default class SlDetails extends ShoelaceElement {
this.detailsObserver.disconnect();
}
private handleSummaryClick(ev: MouseEvent) {
ev.preventDefault();
private handleSummaryClick(event: MouseEvent) {
event.preventDefault();
if (!this.disabled) {
if (this.open) {
this.hide();
} else {
this.show();
}
this.header.focus();
}
}

View File

@@ -1,10 +1,13 @@
import { classMap } from 'lit/directives/class-map.js';
import { getTextContent } from '../../internal/slot.js';
import { getTextContent, HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { property, query } from 'lit/decorators.js';
import { SubmenuController } from './submenu-controller.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
import SlPopup from '../popup/popup.component.js';
import styles from './menu-item.styles.js';
import type { CSSResultGroup } from 'lit';
@@ -15,10 +18,12 @@ import type { CSSResultGroup } from 'lit';
* @since 2.0
*
* @dependency sl-icon
* @dependency sl-popup
*
* @slot - The menu item's label.
* @slot prefix - Used to prepend an icon or similar element to the menu item.
* @slot suffix - Used to append an icon or similar element to the menu item.
* @slot submenu - Used to denote a nested menu.
*
* @csspart base - The component's base wrapper.
* @csspart checked-icon - The checked icon, which is only visible when the menu item is checked.
@@ -26,10 +31,15 @@ import type { CSSResultGroup } from 'lit';
* @csspart label - The menu item label.
* @csspart suffix - The suffix container.
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
*
* @cssproperty [--submenu-offset=-2px] - The distance submenus shift to overlap the parent menu.
*/
export default class SlMenuItem extends ShoelaceElement {
static styles: CSSResultGroup = styles;
static dependencies = { 'sl-icon': SlIcon };
static dependencies = {
'sl-icon': SlIcon,
'sl-popup': SlPopup
};
private cachedTextLabel: string;
@@ -48,6 +58,22 @@ export default class SlMenuItem extends ShoelaceElement {
/** Draws the menu item in a disabled state, preventing selection. */
@property({ type: Boolean, reflect: true }) disabled = false;
private readonly localize = new LocalizeController(this);
private readonly hasSlotController = new HasSlotController(this, 'submenu');
private submenuController: SubmenuController = new SubmenuController(this, this.hasSlotController, this.localize);
connectedCallback() {
super.connectedCallback();
this.addEventListener('click', this.handleHostClick);
this.addEventListener('mouseover', this.handleMouseOver);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('click', this.handleHostClick);
this.removeEventListener('mouseover', this.handleMouseOver);
}
private handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
@@ -64,6 +90,19 @@ export default class SlMenuItem extends ShoelaceElement {
}
}
private handleHostClick = (event: MouseEvent) => {
// Prevent the click event from being emitted when the button is disabled or loading
if (this.disabled) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
private handleMouseOver = (event: MouseEvent) => {
this.focus();
event.stopPropagation();
};
@watch('checked')
handleCheckedChange() {
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
@@ -102,16 +141,28 @@ export default class SlMenuItem extends ShoelaceElement {
return getTextContent(this.defaultSlot);
}
isSubmenu() {
return this.hasSlotController.test('submenu');
}
render() {
const isRtl = this.localize.dir() === 'rtl';
const isSubmenuExpanded = this.submenuController.isExpanded();
return html`
<div
id="anchor"
part="base"
class=${classMap({
'menu-item': true,
'menu-item--rtl': isRtl,
'menu-item--checked': this.checked,
'menu-item--disabled': this.disabled,
'menu-item--has-submenu': false // reserved for future use
'menu-item--has-submenu': this.isSubmenu(),
'menu-item--submenu-expanded': isSubmenuExpanded
})}
?aria-haspopup="${this.isSubmenu()}"
?aria-expanded="${isSubmenuExpanded ? true : false}"
>
<span part="checked-icon" class="menu-item__check">
<sl-icon name="check" library="system" aria-hidden="true"></sl-icon>
@@ -124,8 +175,10 @@ export default class SlMenuItem extends ShoelaceElement {
<slot name="suffix" part="suffix" class="menu-item__suffix"></slot>
<span part="submenu-icon" class="menu-item__chevron">
<sl-icon name="chevron-right" library="system" aria-hidden="true"></sl-icon>
<sl-icon name=${isRtl ? 'chevron-left' : 'chevron-right'} library="system" aria-hidden="true"></sl-icon>
</span>
${this.submenuController.renderSubmenu()}
</div>
`;
}

View File

@@ -5,6 +5,8 @@ export default css`
${componentStyles}
:host {
--submenu-offset: -2px;
display: block;
}
@@ -38,6 +40,8 @@ export default css`
.menu-item .menu-item__label {
flex: 1 1 auto;
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
}
.menu-item .menu-item__prefix {
@@ -64,7 +68,8 @@ export default css`
outline: none;
}
:host(:hover:not([aria-disabled='true'], :focus-visible)) .menu-item {
:host(:hover:not([aria-disabled='true'], :focus-visible)) .menu-item,
.menu-item--submenu-expanded {
background-color: var(--sl-color-neutral-100);
color: var(--sl-color-neutral-1000);
}
@@ -91,6 +96,17 @@ export default css`
visibility: visible;
}
/* Add elevation and z-index to submenus */
sl-popup::part(popup) {
box-shadow: var(--sl-shadow-large);
z-index: var(--sl-z-index-dropdown);
margin-left: var(--submenu-offset);
}
.menu-item--rtl sl-popup::part(popup) {
margin-left: calc(-1 * var(--submenu-offset));
}
@media (forced-colors: active) {
:host(:hover:not([aria-disabled='true'])) .menu-item,
:host(:focus-visible) .menu-item {

View File

@@ -1,7 +1,9 @@
import '../../../dist/shoelace.js';
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlMenuItem from './menu-item';
import type SlSelectEvent from '../../events/sl-select';
describe('<sl-menu-item>', () => {
it('should pass accessibility tests', async () => {
@@ -18,6 +20,21 @@ describe('<sl-menu-item>', () => {
await expect(el).to.be.accessible();
});
it('should pass accessibility tests when using a submenu', async () => {
const el = await fixture<SlMenuItem>(html`
<sl-menu>
<sl-menu-item>
Submenu
<sl-menu slot="submenu">
<sl-menu-item>Submenu Item 1</sl-menu-item>
<sl-menu-item>Submenu Item 2</sl-menu-item>
</sl-menu>
</sl-menu-item>
</sl-menu>
`);
await expect(el).to.be.accessible();
});
it('should have the correct default properties', async () => {
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Test</sl-menu-item> `);
@@ -59,4 +76,98 @@ describe('<sl-menu-item>', () => {
expect(getComputedStyle(item1).display).to.equal('none');
});
it('should not render a sl-popup if the slot="submenu" attribute is missing, but the slot should exist in the component and be hidden.', async () => {
const menu = await fixture<SlMenuItem>(html`
<sl-menu>
<sl-menu-item>
Item 1
<sl-menu>
<sl-menu-item> Nested Item 1 </sl-menu-item>
</sl-menu>
</sl-menu-item>
</sl-menu>
`);
const menuItem: HTMLElement = menu.querySelector('sl-menu-item')!;
expect(menuItem.shadowRoot!.querySelector('sl-popup')).to.be.null;
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
expect(submenuSlot.hidden).to.be.true;
});
it('should render an sl-popup if the slot="submenu" attribute is present', async () => {
const menu = await fixture<SlMenuItem>(html`
<sl-menu>
<sl-menu-item id="test">
Item 1
<sl-menu slot="submenu">
<sl-menu-item> Nested Item 1 </sl-menu-item>
</sl-menu>
</sl-menu-item>
</sl-menu>
`);
const menuItem = menu.querySelector('sl-menu-item')!;
expect(menuItem.shadowRoot!.querySelector('sl-popup')).to.be.not.null;
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
expect(submenuSlot.hidden).to.be.false;
});
it('should focus on first menuitem of submenu if ArrowRight is pressed on parent menuitem', async () => {
const menu = await fixture<SlMenuItem>(html`
<sl-menu>
<sl-menu-item id="item-1">
Submenu
<sl-menu slot="submenu">
<sl-menu-item value="submenu-item-1"> Nested Item 1 </sl-menu-item>
</sl-menu>
</sl-menu-item>
</sl-menu>
`);
const selectHandler = sinon.spy((event: SlSelectEvent) => {
const item = event.detail.item;
expect(item.value).to.equal('submenu-item-1');
});
menu.addEventListener('sl-select', selectHandler);
const submenu = menu.querySelector('sl-menu-item');
submenu!.focus();
await menu.updateComplete;
await sendKeys({ press: 'ArrowRight' });
await menu.updateComplete;
await sendKeys({ press: 'Enter' });
await menu.updateComplete;
// Once for each menu element.
expect(selectHandler).to.have.been.calledTwice;
});
it('should focus on outer menu if ArrowRight is pressed on nested menuitem', async () => {
const menu = await fixture<SlMenuItem>(html`
<sl-menu>
<sl-menu-item value="outer-item-1">
Submenu
<sl-menu slot="submenu">
<sl-menu-item value="inner-item-1"> Nested Item 1 </sl-menu-item>
</sl-menu>
</sl-menu-item>
</sl-menu>
`);
const focusHandler = sinon.spy((event: FocusEvent) => {
expect(event.target.value).to.equal('outer-item-1');
expect(event.relatedTarget.value).to.equal('inner-item-1');
});
const outerItem = menu.querySelector('sl-menu-item');
outerItem!.focus();
await menu.updateComplete;
await sendKeys({ press: 'ArrowRight' });
outerItem.addEventListener('focus', focusHandler);
await menu.updateComplete;
await sendKeys({ press: 'ArrowLeft' });
await menu.updateComplete;
expect(focusHandler).to.have.been.calledOnce;
});
});

View File

@@ -0,0 +1,262 @@
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import { type HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { type LocalizeController } from '../../utilities/localize.js';
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import type SlMenuItem from './menu-item.js';
import type SlPopup from '../popup/popup.js';
/** A reactive controller to manage the registration of event listeners for submenus. */
export class SubmenuController implements ReactiveController {
private host: ReactiveControllerHost & SlMenuItem;
private popupRef: Ref<SlPopup> = createRef();
private enableSubmenuTimer = -1;
private isConnected = false;
private isPopupConnected = false;
private skidding = 0;
private readonly hasSlotController: HasSlotController;
private readonly localize: LocalizeController;
private readonly submenuOpenDelay = 100;
constructor(
host: ReactiveControllerHost & SlMenuItem,
hasSlotController: HasSlotController,
localize: LocalizeController
) {
(this.host = host).addController(this);
this.hasSlotController = hasSlotController;
this.localize = localize;
}
hostConnected() {
if (this.hasSlotController.test('submenu') && !this.host.disabled) {
this.addListeners();
}
}
hostDisconnected() {
this.removeListeners();
}
hostUpdated() {
if (this.hasSlotController.test('submenu') && !this.host.disabled) {
this.addListeners();
this.updateSkidding();
} else {
this.removeListeners();
}
}
private addListeners() {
if (!this.isConnected) {
this.host.addEventListener('mouseover', this.handleMouseOver);
this.host.addEventListener('keydown', this.handleKeyDown);
this.host.addEventListener('click', this.handleClick);
this.host.addEventListener('focusout', this.handleFocusOut);
this.isConnected = true;
}
// The popup does not seem to get wired when the host is
// connected, so manage its listeners separately.
if (!this.isPopupConnected) {
if (this.popupRef.value) {
this.popupRef.value.addEventListener('mouseover', this.handlePopupMouseover);
this.isPopupConnected = true;
}
}
}
private removeListeners() {
if (this.isConnected) {
this.host.removeEventListener('mouseover', this.handleMouseOver);
this.host.removeEventListener('keydown', this.handleKeyDown);
this.host.removeEventListener('click', this.handleClick);
this.host.removeEventListener('focusout', this.handleFocusOut);
this.isConnected = false;
}
if (this.isPopupConnected) {
if (this.popupRef.value) {
this.popupRef.value.removeEventListener('mouseover', this.handlePopupMouseover);
this.isPopupConnected = false;
}
}
}
private handleMouseOver = () => {
if (this.hasSlotController.test('submenu')) {
this.enableSubmenu();
}
};
private handleSubmenuEntry(event: KeyboardEvent) {
// Pass focus to the first menu-item in the submenu.
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
// Missing slot
if (!submenuSlot) {
console.error('Cannot activate a submenu if no corresponding menuitem can be found.', this);
return;
}
// Menus
let menuItems: NodeListOf<Element> | null = null;
for (const elt of submenuSlot.assignedElements()) {
menuItems = elt.querySelectorAll("sl-menu-item, [role^='menuitem']");
if (menuItems.length !== 0) {
break;
}
}
if (!menuItems || menuItems.length === 0) {
return;
}
menuItems[0].setAttribute('tabindex', '0');
for (let i = 1; i !== menuItems.length; ++i) {
menuItems[i].setAttribute('tabindex', '-1');
}
// Open the submenu (if not open), and set focus to first menuitem.
if (this.popupRef.value) {
event.preventDefault();
event.stopPropagation();
if (this.popupRef.value.active) {
if (menuItems[0] instanceof HTMLElement) {
menuItems[0].focus();
}
} else {
this.enableSubmenu(false);
this.host.updateComplete.then(() => {
if (menuItems![0] instanceof HTMLElement) {
menuItems![0].focus();
}
});
this.host.requestUpdate();
}
}
}
// Focus on the first menu-item of a submenu.
private handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Escape':
case 'Tab':
this.disableSubmenu();
break;
case 'ArrowLeft':
// Either focus is currently on the host element or a child
if (event.target !== this.host) {
event.preventDefault();
event.stopPropagation();
this.host.focus();
this.disableSubmenu();
}
break;
case 'ArrowRight':
case 'Enter':
case ' ':
this.handleSubmenuEntry(event);
break;
default:
break;
}
};
private handleClick = (event: MouseEvent) => {
// Clicking on the item which heads the menu does nothing, otherwise hide submenu and propagate
if (event.target === this.host) {
event.preventDefault();
event.stopPropagation();
} else if (
event.target instanceof Element &&
(event.target.tagName === 'sl-menu-item' || event.target.role?.startsWith('menuitem'))
) {
this.disableSubmenu();
}
};
// Close this submenu on focus outside of the parent or any descendants.
private handleFocusOut = (event: FocusEvent) => {
if (event.relatedTarget && event.relatedTarget instanceof Element && this.host.contains(event.relatedTarget)) {
return;
}
this.disableSubmenu();
};
// Prevent the parent menu-item from getting focus on mouse movement on the submenu
private handlePopupMouseover = (event: MouseEvent) => {
event.stopPropagation();
};
private setSubmenuState(state: boolean) {
if (this.popupRef.value) {
if (this.popupRef.value.active !== state) {
this.popupRef.value.active = state;
this.host.requestUpdate();
}
}
}
// Shows the submenu. Supports disabling the opening delay, e.g. for keyboard events that want to set the focus to the
// newly opened menu.
private enableSubmenu(delay = true) {
if (delay) {
this.enableSubmenuTimer = window.setTimeout(() => {
this.setSubmenuState(true);
}, this.submenuOpenDelay);
} else {
this.setSubmenuState(true);
}
}
private disableSubmenu() {
clearTimeout(this.enableSubmenuTimer);
this.setSubmenuState(false);
}
// Calculate the space the top of a menu takes-up, for aligning the popup menu-item with the activating element.
private updateSkidding(): void {
// .computedStyleMap() not always available.
if (!this.host.parentElement?.computedStyleMap) {
return;
}
const styleMap: StylePropertyMapReadOnly = this.host.parentElement.computedStyleMap();
const attrs: string[] = ['padding-top', 'border-top-width', 'margin-top'];
const skidding = attrs.reduce((accumulator, attr) => {
const styleValue: CSSStyleValue = styleMap.get(attr) ?? new CSSUnitValue(0, 'px');
const unitValue = styleValue instanceof CSSUnitValue ? styleValue : new CSSUnitValue(0, 'px');
const pxValue = unitValue.to('px');
return accumulator - pxValue.value;
}, 0);
this.skidding = skidding;
}
isExpanded(): boolean {
return this.popupRef.value ? this.popupRef.value.active : false;
}
renderSubmenu() {
const isLtr = this.localize.dir() === 'ltr';
// Always render the slot, but conditionally render the outer <sl-popup>
if (!this.isConnected) {
return html` <slot name="submenu" hidden></slot> `;
}
return html`
<sl-popup
${ref(this.popupRef)}
placement=${isLtr ? 'right-start' : 'left-start'}
anchor="anchor"
flip
flip-fallback-strategy="best-fit"
skidding="${this.skidding}"
strategy="fixed"
>
<slot name="submenu"></slot>
</sl-popup>
`;
}
}

View File

@@ -1,9 +1,9 @@
import { html } from 'lit';
import { query } from 'lit/decorators.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlMenuItem from '../menu-item/menu-item.component.js';
import styles from './menu.styles.js';
import type { CSSResultGroup } from 'lit';
import type SlMenuItem from '../menu-item/menu-item.js';
export interface MenuSelectEventDetail {
item: SlMenuItem;
}
@@ -29,13 +29,12 @@ export default class SlMenu extends ShoelaceElement {
}
private handleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const item = target.closest('sl-menu-item');
if (!item || item.disabled || item.inert) {
if (!(event.target instanceof SlMenuItem)) {
return;
}
const item: SlMenuItem = event.target;
if (item.type === 'checkbox') {
item.checked = !item.checked;
}
@@ -48,19 +47,21 @@ export default class SlMenu extends ShoelaceElement {
if (event.key === 'Enter' || event.key === ' ') {
const item = this.getCurrentItem();
event.preventDefault();
event.stopPropagation();
// Simulate a click to support @click handlers on menu items that also work with the keyboard
item?.click();
}
// Move the selection when pressing down or up
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
const items = this.getAllItems();
const activeItem = this.getCurrentItem();
let index = activeItem ? items.indexOf(activeItem) : 0;
if (items.length > 0) {
event.preventDefault();
event.stopPropagation();
if (event.key === 'ArrowDown') {
index++;

View File

@@ -20,4 +20,26 @@ describe('<sl-radio-button>', () => {
expect(radio1.checked).to.be.true;
expect(radio2.checked).to.be.false;
});
it('should receive positional classes from <sl-button-group>', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group value="1">
<sl-radio-button id="radio-1" value="1"></sl-radio-button>
<sl-radio-button id="radio-2" value="2"></sl-radio-button>
<sl-radio-button id="radio-3" value="3"></sl-radio-button>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadioButton>('#radio-1')!;
const radio2 = radioGroup.querySelector<SlRadioButton>('#radio-2')!;
const radio3 = radioGroup.querySelector<SlRadioButton>('#radio-3')!;
await Promise.all([radioGroup.updateComplete, radio1.updateComplete, radio2.updateComplete, radio3.updateComplete]);
expect(radio1.classList.contains('sl-button-group__button')).to.be.true;
expect(radio1.classList.contains('sl-button-group__button--first')).to.be.true;
expect(radio2.classList.contains('sl-button-group__button')).to.be.true;
expect(radio2.classList.contains('sl-button-group__button--inner')).to.be.true;
expect(radio3.classList.contains('sl-button-group__button')).to.be.true;
expect(radio3.classList.contains('sl-button-group__button--last')).to.be.true;
});
});

View File

@@ -327,11 +327,8 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
const defaultSlot = html`
<span @click=${this.handleRadioClick} @keydown=${this.handleKeyDown} role="presentation">
<slot @slotchange=${this.syncRadios}></slot>
</span>
<slot @slotchange=${this.syncRadios} @click=${this.handleRadioClick} @keydown=${this.handleKeyDown}></slot>
`;
return html`
@@ -378,7 +375,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
${this.hasButtonGroup
? html`
<sl-button-group part="button-group" exportparts="base:button-group__base">
<sl-button-group part="button-group" exportparts="base:button-group__base" role="presentation">
${defaultSlot}
</sl-button-group>
`
@@ -395,6 +392,5 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
</div>
</fieldset>
`;
/* eslint-enable lit-a11y/click-events-have-key-events */
}
}

View File

@@ -8,6 +8,7 @@ import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { property, query, state } from 'lit/decorators.js';
import { scrollIntoView } from '../../internal/scroll.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
@@ -15,7 +16,7 @@ import SlIcon from '../icon/icon.component.js';
import SlPopup from '../popup/popup.component.js';
import SlTag from '../tag/tag.component.js';
import styles from './select.styles.js';
import type { CSSResultGroup } from 'lit';
import type { CSSResultGroup, TemplateResult } from 'lit';
import type { ShoelaceFormControl } from '../../internal/shoelace-element.js';
import type SlOption from '../option/option.component.js';
import type SlRemoveEvent from '../../events/sl-remove.js';
@@ -172,6 +173,31 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
/** The select's required attribute. */
@property({ type: Boolean, reflect: true }) required = false;
/**
* A function that customizes the tags to be rendered when multiple=true. The first argument is the option, the second
* is the current tag's index. The function should return either a Lit TemplateResult or a string containing trusted HTML of the symbol to render at
* the specified value.
*/
@property() getTag: (option: SlOption, index: number) => TemplateResult | string | HTMLElement = option => {
return html`
<sl-tag
part="tag"
exportparts="
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base
"
?pill=${this.pill}
size=${this.size}
removable
@sl-remove=${(event: SlRemoveEvent) => this.handleTagRemove(event, option)}
>
${option.getTextLabel()}
</sl-tag>
`;
};
/** Gets the validity state object */
get validity() {
return this.valueInput.validity;
@@ -547,6 +573,21 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
this.formControlController.updateValidity();
});
}
protected get tags() {
return this.selectedOptions.map((option, index) => {
if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) {
const tag = this.getTag(option, index);
// Wrap so we can handle the remove
return html`<div @sl-remove=${(e: SlRemoveEvent) => this.handleTagRemove(e, option)}>
${typeof tag === 'string' ? unsafeHTML(tag) : tag}
</div>`;
} else if (index === this.maxOptionsVisible) {
// Hit tag limit
return html`<sl-tag>+${this.selectedOptions.length - index}</sl-tag>`;
}
return html``;
});
}
private handleInvalid(event: Event) {
this.formControlController.setValidity(false);
@@ -755,37 +796,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
@blur=${this.handleBlur}
/>
${this.multiple
? html`
<div part="tags" class="select__tags">
${this.selectedOptions.map((option, index) => {
if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) {
return html`
<sl-tag
part="tag"
exportparts="
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base
"
?pill=${this.pill}
size=${this.size}
removable
@sl-remove=${(event: SlRemoveEvent) => this.handleTagRemove(event, option)}
>
${option.getTextLabel()}
</sl-tag>
`;
} else if (index === this.maxOptionsVisible) {
return html` <sl-tag size=${this.size}> +${this.selectedOptions.length - index} </sl-tag> `;
} else {
return null;
}
})}
</div>
`
: ''}
${this.multiple ? html`<div part="tags" class="select__tags">${this.tags}</div>` : ''}
<input
class="select__value-input"

View File

@@ -168,20 +168,6 @@ export default class SlTree extends ShoelaceElement {
}
};
private syncTreeItems(selectedItem: SlTreeItem) {
const items = this.getAllTreeItems();
if (this.selection === 'multiple') {
syncCheckboxes(selectedItem);
} else {
for (const item of items) {
if (item !== selectedItem) {
item.selected = false;
}
}
}
}
private selectItem(selectedItem: SlTreeItem) {
const previousSelection = [...this.selectedItems];
@@ -190,12 +176,12 @@ export default class SlTree extends ShoelaceElement {
if (selectedItem.lazy) {
selectedItem.expanded = true;
}
this.syncTreeItems(selectedItem);
syncCheckboxes(selectedItem);
} else if (this.selection === 'single' || selectedItem.isLeaf) {
selectedItem.expanded = !selectedItem.expanded;
selectedItem.selected = true;
this.syncTreeItems(selectedItem);
const items = this.getAllTreeItems();
for (const item of items) {
item.selected = item === selectedItem;
}
} else if (this.selection === 'leaf') {
selectedItem.expanded = !selectedItem.expanded;
}
@@ -311,7 +297,7 @@ export default class SlTree extends ShoelaceElement {
return;
}
if (this.selection === 'multiple' && isExpandButton) {
if (isExpandButton) {
treeItem.expanded = !treeItem.expanded;
} else {
this.selectItem(treeItem);

View File

@@ -275,7 +275,6 @@ describe('<sl-tree>', () => {
// Assert
expect(el.selectedItems.length).to.eq(1);
expect(el.children[2]).to.have.attribute('selected');
expect(el.children[2]).to.have.attribute('expanded');
});
});
@@ -439,7 +438,6 @@ describe('<sl-tree>', () => {
await el.updateComplete;
// Assert
expect(node).to.have.attribute('selected');
expect(node).to.have.attribute('expanded');
});
});

View File

@@ -133,10 +133,10 @@ before(async () => {
relevantMetadata.forEach(({ tagName, path }) => {
it(`Should not register any components: ${tagName}`, async () => {
// Check if importing the files automatically registers any components
await import('../../dist/' + path);
const registeredTags = tagNames.filter(tag => Boolean(window.customElements.get(tag)));
// Need to make sure we remove the current tag from the tagNames and *then* see whats been registered.
const registeredTags = tagNames.filter(tag => tag !== tagName && Boolean(window.customElements.get(tag)));
const errorMessage =
`Expected ${path} to not register any tags, but it registered the following tags: ` +