Skip to content

Commit a64d7ce

Browse files
committed
Introduce disabled state for TreeView items
1 parent 4e4c5ba commit a64d7ce

11 files changed

Lines changed: 339 additions & 110 deletions

File tree

app/components/primer/alpha/tree_view.pcss

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@
143143
text-decoration-color: var(--control-fgColor-rest);
144144
}
145145

146+
& :has(.TreeViewItemContent[aria-disabled=true]) {
147+
cursor: not-allowed;
148+
}
149+
146150
& .TreeViewItemContent {
147151
display: flex;
148152
height: 100%;
@@ -208,6 +212,37 @@
208212
}
209213
}
210214
}
215+
216+
&[aria-disabled='true'] {
217+
pointer-events: none;
218+
219+
& .TreeViewItemContentText {
220+
color: var(--control-fgColor-disabled);
221+
}
222+
223+
& .TreeViewItemVisual {
224+
fill: var(--control-fgColor-disabled);
225+
226+
& svg {
227+
fill: var(--control-fgColor-disabled);
228+
}
229+
}
230+
231+
& .FormControl-checkbox {
232+
@media (hover: hover) {
233+
&:hover {
234+
cursor: not-allowed;
235+
}
236+
}
237+
}
238+
239+
@media (hover: hover) {
240+
&:hover {
241+
cursor: not-allowed;
242+
background-color: transparent;
243+
}
244+
}
245+
}
211246
}
212247

213248
& .TreeViewItemContentText {

app/components/primer/alpha/tree_view/node.rb

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ class Node < Primer::Component
4848
# @return [Symbol]
4949
attr_reader :node_variant
5050

51+
# Whether or not this node is disabled, i.e. cannot be activated.
52+
#
53+
# @return [Boolean]
54+
attr_reader :disabled
55+
alias disabled? disabled
56+
5157
DEFAULT_SELECT_VARIANT = :none
5258
SELECT_VARIANT_OPTIONS = [
5359
:multiple,
@@ -67,6 +73,7 @@ class Node < Primer::Component
6773
# @param current [Boolean] Whether or not this node is the current node. The current node is styled differently than regular nodes and is the first element that receives focus when tabbing to the `TreeView` component.
6874
# @param select_variant [Symbol] Controls the type of checkbox that appears. <%= one_of(Primer::Alpha::TreeView::Node::SELECT_VARIANT_OPTIONS) %>
6975
# @param checked [Boolean | String] The checked state of the node's checkbox. <%= one_of(Primer::Alpha::TreeView::Node::CHECKED_STATES) %>
76+
# @param disabled [Boolean] Whether or not the node can be activated. Passing `false` here will cause the node to appear visually disabled but it is still keyboard-focusable.
7077
# @param content_arguments [Hash] Arguments attached to the node's content, i.e the `<button>` or `<a>` element. <%= link_to_system_arguments_docs %>
7178
def initialize(
7279
path:,
@@ -75,6 +82,7 @@ def initialize(
7582
current: false,
7683
select_variant: DEFAULT_SELECT_VARIANT,
7784
checked: DEFAULT_CHECKED_STATE,
85+
disabled: false,
7886
**content_arguments
7987
)
8088
@system_arguments = {
@@ -89,6 +97,7 @@ def initialize(
8997
@current = current
9098
@select_variant = fetch_or_fallback(SELECT_VARIANT_OPTIONS, select_variant, DEFAULT_SELECT_VARIANT)
9199
@checked = fetch_or_fallback(CHECKED_STATES, checked, DEFAULT_CHECKED_STATE)
100+
@disabled = disabled
92101
@node_variant = fetch_or_fallback(NODE_VARIANT_TAG_OPTIONS, node_variant, DEFAULT_NODE_VARIANT)
93102

94103
@content_arguments[:tag] = NODE_VARIANT_TAG_MAP[@node_variant]
@@ -103,13 +112,14 @@ def initialize(
103112

104113
@content_arguments[:aria] = merge_aria(
105114
@content_arguments, {
106-
aria: {
107-
level: level,
108-
selected: false,
109-
checked: checked,
110-
labelledby: content_id
111-
}
115+
aria: {
116+
level: level,
117+
selected: false,
118+
checked: checked,
119+
labelledby: content_id,
120+
disabled: disabled?
112121
}
122+
}
113123
)
114124

115125
@content_arguments[:data] = merge_data(

app/components/primer/alpha/tree_view/tree_view.ts

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,21 +58,21 @@ export class TreeViewElement extends HTMLElement {
5858
}
5959

6060
#nodeForEvent(event: Event): Element | null {
61-
const target = event.target as Element
62-
const node = target.closest('[role=treeitem]')
61+
const eventTarget = event.target as Element
62+
const node = eventTarget.closest('[role=treeitem]')
6363
if (!node) return null
6464

65-
if (target.closest('.TreeViewItemToggle')) return null
66-
if (target.closest('.TreeViewItemLeadingAction')) return null
65+
if (eventTarget.closest('.TreeViewItemToggle')) return null
66+
if (eventTarget.closest('.TreeViewItemLeadingAction')) return null
6767

6868
return node
6969
}
7070

7171
#handleNodeEvent(node: Element, event: Event) {
7272
if (this.#eventIsCheckboxToggle(event, node)) {
73-
this.#handleCheckboxToggle(node)
73+
this.#handleCheckboxToggle(event, node)
7474
} else if (this.#eventIsActivation(event)) {
75-
this.#handleNodeActivated(node)
75+
this.#handleNodeActivated(event, node)
7676
} else if (event.type === 'focusin') {
7777
this.#handleNodeFocused(node)
7878
} else if (event instanceof KeyboardEvent) {
@@ -84,19 +84,30 @@ export class TreeViewElement extends HTMLElement {
8484
return event.type === 'click' && this.nodeHasCheckBox(node)
8585
}
8686

87-
#handleCheckboxToggle(node: Element) {
88-
// only handle checking of leaf nodes
87+
#handleCheckboxToggle(event: Event, node: Element) {
88+
if (this.getNodeDisabledValue(node)) {
89+
event.preventDefault()
90+
return
91+
}
92+
93+
// only handle checking of leaf nodes, see TreeViewSubTreeNodeElement for the code that
94+
// handles checking sub tree items.
8995
const type = this.getNodeType(node)
9096
if (type !== 'leaf') return
9197

9298
if (this.getNodeCheckedValue(node) === 'true') {
93-
this.#setNodeCheckedValue(node, 'false')
99+
this.setNodeCheckedValue(node, 'false')
94100
} else {
95-
this.#setNodeCheckedValue(node, 'true')
101+
this.setNodeCheckedValue(node, 'true')
96102
}
97103
}
98104

99-
#handleNodeActivated(node: Element) {
105+
#handleNodeActivated(event: Event, node: Element) {
106+
if (this.getNodeDisabledValue(node)) {
107+
event.preventDefault()
108+
return
109+
}
110+
100111
// do not emit activation events for buttons and anchors, since it is assumed any activation
101112
// behavior for these element types is user- or browser-defined
102113
if (!(node instanceof HTMLDivElement)) return
@@ -140,13 +151,18 @@ export class TreeViewElement extends HTMLElement {
140151
switch (event.key) {
141152
case ' ':
142153
case 'Enter':
154+
if (this.getNodeDisabledValue(node)) {
155+
event.preventDefault()
156+
break
157+
}
158+
143159
if (this.nodeHasCheckBox(node)) {
144160
event.preventDefault()
145161

146162
if (this.getNodeCheckedValue(node) === 'true') {
147-
this.#setNodeCheckedValue(node, 'false')
163+
this.setNodeCheckedValue(node, 'false')
148164
} else {
149-
this.#setNodeCheckedValue(node, 'true')
165+
this.setNodeCheckedValue(node, 'true')
150166
}
151167
} else if (node instanceof HTMLAnchorElement) {
152168
// simulate click on space
@@ -209,14 +225,14 @@ export class TreeViewElement extends HTMLElement {
209225
const node = this.nodeAtPath(path)
210226
if (!node) return
211227

212-
this.#setNodeCheckedValue(node, 'true')
228+
this.setNodeCheckedValue(node, 'true')
213229
}
214230

215231
uncheckAtPath(path: string[]) {
216232
const node = this.nodeAtPath(path)
217233
if (!node) return
218234

219-
this.#setNodeCheckedValue(node, 'false')
235+
this.setNodeCheckedValue(node, 'false')
220236
}
221237

222238
toggleCheckedAtPath(path: string[]) {
@@ -239,6 +255,13 @@ export class TreeViewElement extends HTMLElement {
239255
return this.getNodeCheckedValue(node)
240256
}
241257

258+
disabledValueAtPath(path: string[]): boolean {
259+
const node = this.nodeAtPath(path)
260+
if (!node) return false
261+
262+
return this.getNodeDisabledValue(node)
263+
}
264+
242265
nodeAtPath(path: string[], selector?: string): Element | null {
243266
const pathStr = JSON.stringify(path)
244267
return this.querySelector(`${selector || ''}[data-path="${CSS.escape(pathStr)}"]`)
@@ -255,14 +278,26 @@ export class TreeViewElement extends HTMLElement {
255278
return this.nodeAtPath(path, '[data-node-type=leaf]') as HTMLLIElement | null
256279
}
257280

258-
#setNodeCheckedValue(node: Element, value: TreeViewCheckedValue) {
281+
setNodeCheckedValue(node: Element, value: TreeViewCheckedValue) {
259282
node.setAttribute('aria-checked', value.toString())
260283
}
261284

262285
getNodeCheckedValue(node: Element): TreeViewCheckedValue {
263286
return (node.getAttribute('aria-checked') || 'false') as TreeViewCheckedValue
264287
}
265288

289+
getNodeDisabledValue(node: Element): boolean {
290+
return node.getAttribute('aria-disabled') === 'true'
291+
}
292+
293+
setNodeDisabledValue(node: Element, disabled: boolean) {
294+
if (disabled) {
295+
node.setAttribute('aria-disabled', 'true')
296+
} else {
297+
node.removeAttribute('aria-disabled')
298+
}
299+
}
300+
266301
nodeHasCheckBox(node: Element): boolean {
267302
return node.querySelector('.TreeViewItemCheckbox') !== null
268303
}

app/components/primer/alpha/tree_view/tree_view_sub_tree_node_element.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,11 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
299299

300300
switch (event.key) {
301301
case 'Enter':
302+
if (this.treeView?.getNodeDisabledValue(node)) {
303+
event.preventDefault()
304+
break
305+
}
306+
302307
// eslint-disable-next-line no-restricted-syntax
303308
event.stopPropagation()
304309

@@ -324,6 +329,11 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
324329
break
325330

326331
case ' ':
332+
if (this.treeView?.getNodeDisabledValue(node)) {
333+
event.preventDefault()
334+
break
335+
}
336+
327337
if (this.#checkboxElement) {
328338
// eslint-disable-next-line no-restricted-syntax
329339
event.stopPropagation()
@@ -344,6 +354,11 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
344354
}
345355

346356
#handleCheckboxEvent(event: Event) {
357+
if (this.treeView?.getNodeDisabledValue(this.node)) {
358+
event.preventDefault()
359+
return
360+
}
361+
347362
if (event.type !== 'click') return
348363

349364
this.toggleChecked()
@@ -354,7 +369,7 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
354369
}
355370

356371
toggleChecked() {
357-
const checkValue = this.node.getAttribute('aria-checked') || 'false'
372+
const checkValue = this.treeView?.getNodeCheckedValue(this.node) || 'false'
358373
const newCheckValue = checkValue === 'false' ? 'true' : 'false'
359374
const nodeInfos: TreeViewNodeInfo[] = []
360375
const rootInfo = this.treeView?.infoFromNode(this.node, newCheckValue)

previews/primer/alpha/tree_view_preview.rb

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@ class TreeViewPreview < ViewComponent::Preview
88
#
99
# @snapshot interactive
1010
# @param expanded [Boolean] toggle
11+
# @param disabled [Boolean] toggle
1112
# @param select_variant [Symbol] select [multiple, none]
1213
# @param select_strategy [Symbol] select [self, descendants]
1314
def default(
1415
expanded: false,
16+
disabled: false,
1517
select_variant: Primer::Alpha::TreeView::Node::DEFAULT_SELECT_VARIANT,
1618
select_strategy: Primer::Alpha::TreeView::SubTreeNode::DEFAULT_SELECT_STRATEGY
1719
)
1820
render_with_template(locals: {
1921
expanded: coerce_bool(expanded),
22+
disabled: coerce_bool(disabled),
2023
select_variant: select_variant.to_sym,
2124
select_strategy: select_strategy.to_sym
2225
})
@@ -92,37 +95,44 @@ def async_alpha(action_menu_expanded: false)
9295
# @param leading_action_icon [Symbol] octicon
9396
# @param trailing_visual_icon [Symbol] octicon
9497
# @param select_variant [Symbol] select [multiple, none]
98+
# @param disabled [Boolean] toggle
9599
def leaf_node_playground(
96100
label: "Leaf node",
97101
leading_visual_icon: nil,
98102
leading_action_icon: nil,
99103
trailing_visual_icon: nil,
100-
select_variant: Primer::Alpha::TreeView::Node::DEFAULT_SELECT_VARIANT
104+
select_variant: Primer::Alpha::TreeView::Node::DEFAULT_SELECT_VARIANT,
105+
disabled: false
101106
)
102107
render_with_template(locals: {
103108
label: label,
104109
leading_visual_icon: leading_visual_icon,
105110
leading_action_icon: leading_action_icon,
106111
trailing_visual_icon: trailing_visual_icon,
107-
select_variant: select_variant.to_sym
112+
select_variant: select_variant.to_sym,
113+
disabled: disabled
108114
})
109115
end
110116

111117
# @label Links
112118
#
113119
# @param expanded [Boolean] toggle
114-
def links(expanded: false)
120+
# @param disabled [Boolean] toggle
121+
def links(expanded: false, disabled: false)
115122
render_with_template(locals: {
116-
expanded: coerce_bool(expanded)
123+
expanded: coerce_bool(expanded),
124+
disabled: coerce_bool(disabled)
117125
})
118126
end
119127

120128
# @label Buttons
121129
#
122130
# @param expanded [Boolean] toggle
123-
def buttons(expanded: false)
131+
# @param disabled [Boolean] toggle
132+
def buttons(expanded: false, disabled: false)
124133
render_with_template(locals: {
125-
expanded: coerce_bool(expanded)
134+
expanded: coerce_bool(expanded),
135+
disabled: coerce_bool(disabled)
126136
})
127137
end
128138

@@ -161,7 +171,7 @@ def populate(node, data, node_arguments)
161171

162172
entries = (
163173
data.fetch("children", {}).keys.map { |label, idx| [label, :directory] } +
164-
data.fetch("files", []).map { |label| [label, :file] }
174+
data.fetch("files", []).map { |label| [label, :file] }
165175
)
166176

167177
entries.sort_by!(&:first)
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<div style="max-width: 400px">
22
<%= render(Primer::Alpha::TreeView.new(node_variant: :button)) do |tree_view| %>
3-
<% tree_view.with_sub_tree(label: "Secrets", expanded: expanded, onclick: "alert('Shhhh')") do |sub_tree| %>
4-
<% sub_tree.with_leaf(label: "Life and the universe", onclick: "alert(42)") %>
5-
<% sub_tree.with_leaf(label: "Secret ingredient", onclick: "alert('Love')") %>
3+
<% tree_view.with_sub_tree(label: "Secrets", expanded: expanded, onclick: "alert('Shhhh')", disabled: disabled) do |sub_tree| %>
4+
<% sub_tree.with_leaf(label: "Life and the universe", onclick: "alert(42)", disabled: disabled) %>
5+
<% sub_tree.with_leaf(label: "Secret ingredient", onclick: "alert('Love')", disabled: disabled) %>
66
<% end %>
77

8-
<% tree_view.with_leaf(label: "What do you give a sick bird?", onclick: "alert('Tweetment')") %>
8+
<% tree_view.with_leaf(label: "What do you give a sick bird?", onclick: "alert('Tweetment')", disabled: disabled) %>
99
<% end %>
1010
</div>

0 commit comments

Comments
 (0)